Compare commits

...
Author SHA1 Message Date
claude[bot] 1df463d7cf docs: review document for #579
Проверка (CI) / Классификация изменённых файлов (push) Successful in 29s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 34s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Failing after 1m25s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 33s
Issue: #579
User-Visible: no
2026-09-14 20:24:25 +00:00
Sergey Matyunin 2c7abe7938 test: cover live viewport effects matrix (#579)
Issue: #579
User-Visible: no
2026-09-14 23:08:32 +03:00
claude[bot] 9bae4d283a docs: review document for #579
Issue: #579
User-Visible: no
2026-09-14 20:00:07 +00:00
Sergey Matyunin 216990e469 fix: keep SVG compositor stable during pinch (#579)
Issue: #579
User-Visible: yes
2026-09-14 22:28:09 +03:00
claude[bot] 0cbc7aff7a docs: review document for #579
Issue: #579
User-Visible: no
2026-09-14 19:01:08 +00:00
claude[bot] 856a2c6e12 docs: review document for #578
Проверка (CI) / Классификация изменённых файлов (push) Successful in 21s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 20s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 1m4s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m32s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 4m1s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Successful in 5m12s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 13m42s
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m18s
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 17s
Issue: #578
User-Visible: no
2026-09-14 18:47:36 +00:00
Sergey Matyunin 5790abaa1f fix: suppress device activation during touch pinch (#578)
Issue: #578
User-Visible: yes
2026-09-14 21:23:58 +03:00
claude[bot] fcce2af1ff docs: review document for #578
Issue: #578
User-Visible: no
2026-09-14 18:06:47 +00:00
Sergey Matyunin ad4000f95a Accept v1.76.0-beta.3 isometric golden baselines
Accept the 13 visually reviewed Stage 4 isometric frames from the complete Linux Validate artifact. The other 156 baselines remain unchanged and 101 environment witnesses match.

Issue: #570
User-Visible: no
Release: v1.76.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34853080375
2026-09-14 17:32:21 +03:00
Sergey Matyunin e5fe4364dc Prepare v1.76.0-beta.3
Package the validated Stage 4 candidate while keeping the isometric experiment hidden and the default Flat experience unchanged.

Release: v1.76.0-beta.3
Issue: #570
User-Visible: yes
2026-09-14 17:02:49 +03:00
claude[bot] 25a9607618 docs: review document for #570
Issue: #570
User-Visible: no
2026-09-14 13:21:29 +00:00
Sergey Matyunin 258896cb6f feat: refine hidden isometric Stage 4 (#570)
Issue: #570
User-Visible: no
2026-09-14 15:29:57 +03:00
claude[bot] 807d9917c0 docs: review document for #570
Issue: #570
User-Visible: no
2026-09-14 11:56:55 +00:00
Sergey Matyunin c22c04d647 Prepare v1.76.0-beta.2
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m20s
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 1m54s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m28s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Failing after 2m19s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Failing after 3m22s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Failing after 3m45s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Failing after 1m36s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Failing after 38s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 21s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 2m36s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Failing after 2m40s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Failing after 14m19s
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 15s
Package the validated beta candidate with the current S8 membership and synchronized release metadata.

Release: v1.76.0-beta.2
Issue: #556
Issue: #558
Issue: #560
Issue: #563
Issue: #564
Issue: #565
Issue: #566
Issue: #567
Issue: #568
Issue: #569
User-Visible: yes
2026-09-14 10:34:03 +03:00
claude[bot] e66ce808d4 docs: review document for #565
Issue: #565
User-Visible: no
2026-09-14 07:21:41 +00:00
Sergey Matyuninandclaude[bot] dded4fbd5c fix: сохранить клавиатурную подсказку (#565)
Issue: #565
User-Visible: yes
2026-09-14 07:06:16 +00:00
claude[bot] c23c7927de docs: review document for #565
Issue: #565
User-Visible: no
2026-09-14 07:06:16 +00:00
Sergey Matyuninandclaude[bot] 6aca447fa0 fix: улучшить доступность View (#565)
Issue: #565
User-Visible: yes
2026-09-14 07:06:16 +00:00
claude[bot] 6103e7de68 docs: review document for #565
Issue: #565
User-Visible: no
2026-09-14 07:06:16 +00:00
claude[bot] 84a7773e29 docs: review document for #569
Issue: #569
User-Visible: no
2026-09-14 07:04:18 +00:00
Codex 542650234d Девять свидетелей снова доказывают, а не падают на компиляции
Первый ночной прогон с честным критерием (#550) дал 726 из 735: девять мутантов
не доходили до заявленного теста. Причины оказались тремя, а не одной, как я
ожидал по трём случаям из #566:

- статически мёртвая ветка (`if (false && …)`, `if (true) throw`) — TypeScript
  теряет сужение, сделанное выше: `united`, `_relation`, `previous`, остаток
  функции после безусловного throw. Лечится ложью, ложной в рантайме, но не
  статически;
- пустой литерал `[]` выводится как `never[]`, и падают уже вызывающие. Лечится
  сохранением типа при потере содержимого (`slice(0, 0)`, `as string[]`);
- несовпадение типов в самой замене: `canonicalizeNumber` отдаёт `unknown` там,
  где нужен `T`; подмена резолвера литералом сужала union, и ветка
  `resolution.reason` становилась `never`; бракованная метка `case` ломала
  `switch` по union.

Смысл каждого мутанта сохранён: все девять прогнаны по `--id=` и краснят свой
заявленный тест.

Issue: #569
User-Visible: no
2026-09-14 09:51:26 +03:00
claude[bot] 7d08a28de7 docs: review document for #568
Issue: #568
User-Visible: no
2026-09-14 06:12:33 +00:00
CodexandCodex 264fba0902 Отказ подготовки называет виновника
Свидетель, который не готовится к прогону, краснил гейт той задачи, чей дифф его
выбрал, даже когда причина лежала в чужом коммите: на #566 это стоило двух
кругов. Теперь при исходе `setup-failure` в дифф-режиме прогоняется ОПРЕДЕЛЕНИЕ
БАЗЫ на дереве базы, и раннер говорит прямо — отказ предсуществующий или внесён
этим диффом. Сравнение подобного с подобным здесь принципиально: с определением
из головы собственная сломанная правка реестра выглядела бы предсуществующей.

Предсуществующий гейт задачи не красит (решение владельца 14.09) и не теряется:
он назван машиночитаемой строкой и обязан покраснеть в ночном полном прогоне, у
которого есть адресат (#472). Мутанта, которого в базе нет, оправдывать нечем по
построению.

Композиция чтения реестра базы и запуска мутанта живёт отдельным модулем:
границу «запуск не зависит от отбора» держит тест #558, и CLI обязан остаться
тонким.

Issue: #568
User-Visible: no
2026-09-14 08:55:17 +03:00
claude[bot] 60f93a10ed docs: review document for #564
Проверка (CI) / Классификация изменённых файлов (push) Successful in 14s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 32s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 16s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 20s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Failing after 46s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m11s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 14s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Issue: #564
User-Visible: no
2026-09-14 01:14:06 +00:00
Sergey Matyunin 032616f223 test: покрыть реальные капсулы маркеров (#564)
Issue: #564
User-Visible: no
2026-09-14 03:45:29 +03:00
claude[bot] 27126ad367 docs: review document for #564
Issue: #564
User-Visible: no
2026-09-14 00:38:19 +00:00
Sergey Matyunin 0af582daa5 test: защитить hover-gate маркеров (#564)
Issue: #564
User-Visible: no
2026-09-14 03:16:00 +03:00
Sergey Matyunin ba74cd1556 test: сверить слои Zigbee с арбитражем маркеров (#564)
Issue: #564
User-Visible: no
2026-09-14 02:57:50 +03:00
Sergey Matyunin 412ca0f6ce docs: привязать кадры к исходникам #564
Issue: #564
User-Visible: no
2026-09-14 02:57:50 +03:00
Sergey Matyunin f9ed9d2ae8 fix: сохранить точную активацию маркера (#564)
Issue: #564
User-Visible: yes
2026-09-14 02:57:50 +03:00
Sergey Matyunin 6de854a03d fix: разрешить перекрытия целей маркеров (#564)
Issue: #564
User-Visible: yes
2026-09-14 02:57:50 +03:00
claude[bot]andSergey Matyunin fcc6c747a3 docs: review document for #564
Issue: #564
User-Visible: no
2026-09-14 02:57:32 +03:00
claude[bot]andSergey Matyunin 9fd9e6d978 docs: review document for #564
Issue: #564
User-Visible: no
2026-09-14 02:57:32 +03:00
claude[bot] a65c88bc11 docs: review document for #566
Issue: #566
User-Visible: no
2026-09-13 23:45:20 +00:00
CodexandCodex 733cd5b86b Три состояния владельца позиции вместо двух
Ревью r1, Medium: наблюдение о записи на удалённом пространстве говорило
«владелец жив» и про ключ, который ни во что не резолвится. Это заявление о
доказанности там, где её нет: продукт в этом состоянии ничего не удаляет не
потому, что владелец жив, а потому, что не знает — `space-reference-repair`
ведёт `live`, `absent` и `unverified`, три состояния, а не два.

Проверка ведёт те же три. Нерезолвящийся ключ получает наблюдение
`unknown_owner` с той же формулировкой, что и в живом пространстве: «владелец не
найден в конфигурации (возможно устройство HA)». Тест сверяет теперь и ТЕКСТ
причины — вид наблюдения без текста эту асимметрию пропускал, ровно так она и
проехала.

Issue: #566
User-Visible: no
2026-09-14 02:28:50 +03:00
claude[bot] 9cb1f4be6c docs: review document for #566
Issue: #566
User-Visible: no
2026-09-13 23:22:47 +00:00
Codex f724cca61a Третий мутант того же покроя снова компилируется
`optimizer-micro-interval-cleanup-disabled` вставлял безусловный `return` в
начало функции. Остаток функции становился недостижимым, и там TypeScript терял
сужение `profile` — компиляция падала до теста. Возврат сделан под
рантайм-условием: смысл мутанта («очистка отключена») тот же, остаток кода для
проверки типов остаётся достижимым.

Issue: #566
User-Visible: no
2026-09-14 01:56:25 +03:00
Codex 7ecdafef94 Два мутанта реестра снова компилируются
`inner-span-reads-whole-edge-thickness` и `safe-resize-legacy-midpoint-fail-open`
падали на `npx tsc -p tsconfig.test.json` до запуска заявленного теста и потому
не проверяли ничего. Причина одна: патч делал ветку статически мёртвой
(`if (false)`, `false &&`), а в мёртвой ветке TypeScript теряет сужение типов,
сделанное выше — `profile` снова `| null`, `direct` снова `| undefined`.

Условия заменены на ложные в рантайме, но не статически. Смысл мутантов тот же,
и оба теперь ловятся заявленными тестами. Обнаружено прогоном на этой ветке:
правка реестра затягивает в отбор мутантов чужие файлы, и красным стал гейт
задачи, к которой эти записи отношения не имеют. Провал такого мутанта
незаметен, пока его не выберет дифф — это отдельный пробел, #568.

Issue: #566
User-Visible: no
2026-09-14 01:40:23 +03:00
Codex dedcedc8cc Позиция на удалённом пространстве судится по владельцу
Инварианты объявляли нарушением ЛЮБУЮ запись layout, чьё пространство удалено.
Продукт так не считает: `space-reference-repair` удаляет такую запись только
когда может доказать, что владелец тоже исчез, и сознательно хранит её, когда
владелец жив или доказательств нет — удаление уносит расстановку пользователя.
Конфиг сразу после Optimize законно содержит такие записи, и проверка называла
нарушением штатное состояние.

Теперь правило то же, что у продукта: нарушение — только когда владельца нет по
самой конфигурации (комната, область или снятый маркер). Остальное —
наблюдение, как у ветки `unknown_owner` рядом.

Issue: #566
User-Visible: no
2026-09-14 01:27:58 +03:00
claude[bot] dbb1da5d25 docs: review document for #567
Issue: #567
User-Visible: no
2026-09-13 20:34:12 +00:00
Codex 03983946c5 Источники радара сравниваются по смыслу, а не по тексту
Гард `keepTwoPoint` решал, сохранять ли двухточечную калибровку, сравнением
`JSON.stringify`, то есть текста. Порядок ключей `sources` при этом меняет сам
билдер: `slots`/`ranges`/`zones`/`occupancy_entity`/`count_entity` он удаляет и
дописывает заново, а `availability_entity` остаётся на месте и уезжает в начало.
Конфиг, только что записанный этим же билдером, при следующем открытии
сравнивался неравным — и радар с `availability_entity` терял `refs` и `rms_cm`,
получая `method: manual`, при первом же обычном сохранении.

Сравнение стало каноничным по порядку ключей объектов и осталось чувствительным
к порядку элементов массивов: позиция слота — это его `target_N`. Проекция не
затронута — она считается от `mount`, `cell_cm` и `mirror`.

Issue: #567
User-Visible: no
2026-09-13 23:23:30 +03:00
claude[bot] 6706182a27 docs: review document for #560
Проверка (CI) / HACS: валидация репозитория (push) Failing after 4m0s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 26s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 5m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 1m44s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Successful in 6m29s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 10m20s
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 15s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 39s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 2m28s
Issue: #560
User-Visible: no
2026-09-13 18:25:53 +00:00
Codex db7eef5306 Корпус геометрий, household-сценарии и аудит доступности View
Полевая проверка основного View по #560: шесть обезличенных планов корпуса и
цепочка import → Optimize → Optimize → Resize → сохранение с численными
оракулами, семь household-путей с независимыми оракулами в двух ширинах
карточки, аудит доступности с измеренными числами. Продуктовых правок нет:
находки заведены отдельно (#564, #565, #566).

Issue: #560
User-Visible: no
2026-09-13 21:05:28 +03:00
claude[bot] a03ccd65a5 docs: review document for #558
Issue: #558
User-Visible: no
2026-09-13 17:44:19 +00:00
Sergey Matyunin c53fd1a54f refactor: split mutation gate modules (#558)
Issue: #558
User-Visible: no
2026-09-13 20:29:35 +03:00
claude[bot] 5c850ad01f docs: review document for #556
Issue: #556
User-Visible: no
2026-09-13 17:16:43 +00:00
CodexandCodex 2642fdb446 Мутант: запись в репозиторий у недоверенной стадии
Issue: #556
User-Visible: no
2026-09-13 19:59:59 +03:00
CodexandCodex e0098c8d00 Права модели в ревью держит job-scoped токен, а не App-обмен
Объявленные `permissions:` у `model_review` не были потолком: без переданного
`github_token` claude-code-action меняет OIDC на собственный App-токен, дефолт
которого — contents/issues/pull_requests: write, и `ghs_…` от claude[bot]
оказывался прямо в окружении Bash-инструмента модели. Ревью r1 показало это
живым доказательством в собственной же сессии.

Теперь шагу Review передан ambient `secrets.GITHUB_TOKEN`: обмена не происходит,
`id-token` не нужен, список прав становится настоящим. У модели остаётся ровно
одно право записи — `issues: write` под комментарий вердикта (§7.2) и issue по
§12; записи в репозиторий у неё больше нет.

Issue: #556
User-Visible: no
2026-09-13 19:59:59 +03:00
claude[bot] 717308f0fb docs: review document for #556
Issue: #556
User-Visible: no
2026-09-13 16:45:20 +00:00
Codexandclaude[bot] 954eeff45a Обновление пина описано, воркфлоу перестали быть «не входом»
Комментарий с версией рядом с SHA — единственное, что говорит читателю, какой
релиз держали в руках; `docs/DEVELOPMENT.md` описывает, как посмотреть, что
сейчас за тегом, и заменить обе части одним коммитом. Отдельно записано, что у
`home-assistant/actions` и `hacs/action` тегов нет вовсе — там в комментарии
стоит дата, на которую читалась голова ветки.

Запись `NOT_AN_INPUT` для `.github/workflows/*.yml` снята: воркфлоу читает
проверка пинов, то есть они теперь честный вход, а не «у каждого свой запуск».

Issue: #556
User-Visible: no
2026-09-13 16:24:10 +00:00
Codexandclaude[bot] b35551884f Сторонние Actions закреплены SHA, права выданы по job, граница проверяется фикстурами
Три вещи, которые аудит 12.09 назвал в §10.

**Перемещаемые ссылки.** `home-assistant/actions/hassfest@master` и
`hacs/action@main` — это произвольный будущий коммит чужой ветки, а ревьюера с
Read/Write/Bash запускал перемещаемый major `anthropics/claude-code-action@v1`.
Все 116 `uses:` в девяти воркфлоу закреплены полным SHA с комментарием-версией;
`scripts/action-pins.mjs` это проверяет, а предполётный вердикт Validate —
исполняет. Локальная переиспользуемая workflow пина не требует и исключена
явно.

**Права.** Один блок `permissions` на весь конвейер выдавал `issues: write` и
OIDC каждой стадии, включая единственную недоверенную — работу модели. Теперь
права выдаются по job: модели только чтение и OIDC для самой
`claude-code-action`, писать в issue умеют детерминированные стадии.

**Граница.** Разбор запечатанного результата переехал из inline-shell в
`scripts/review-result-gate.mjs` — не ради красоты, а потому что в YAML его
нельзя прогнать ни одним отрицательным случаем. Проверяются те же вещи, что и
раньше, и в том же объёме: точный набор файлов, контрольные суммы, схема
паспорта и совпадение КАЖДОГО из семнадцати полей с тем, что посчитала
детерминированная стадия. Сверху — пятнадцать враждебных фикстур: неполный
набор, лишний файл, подменённое содержимое, чужой run и попытка, устаревший
material_sha и tree, чужие задача, этап, раунд и ветка, вердикт вне словаря,
пустой документ, manifest не о тех файлах, неразбираемый JSON.

Настоящих секретов и привилегированных операций фикстуры не трогают.

Issue: #556
User-Visible: no
2026-09-13 16:24:10 +00:00
claude[bot] ad886b7f3c docs: review document for #563
Issue: #563
User-Visible: no
2026-09-13 16:11:16 +00:00
Sergey Matyunin cc0b41ba61 test: восстановить запас bundle-ratchet для #563
Issue: #563
User-Visible: no
2026-09-13 18:53:06 +03:00
Sergey Matyunin aa13e2264e fix: блокировать нажатия устройств после pinch (#563)
Issue: #563
User-Visible: yes
2026-09-13 18:44:07 +03:00
claude[bot] 8fa2568cf9 docs: review document for #563
Issue: #563
User-Visible: no
2026-09-13 15:34:08 +00:00
Sergey Matyunin e36767773a test: make mutation oracles explicit before v1.76.0-beta.1
Run 34760156615 exposed stale registry witnesses under the honest #550 outcome taxonomy. Keep behavioural checks out of setup chains, retarget the preflight mutation to the editor host, and make the junction cache smoke exercise same-object in-place geometry changes.

Release: v1.76.0-beta.1
Issue: #550
User-Visible: no
2026-09-13 17:06:24 +03:00
Sergey Matyunin c8b3606c57 Merge main workflow activation into v1.76.0-beta.1 candidate
Issue: #555
User-Visible: no
Release: v1.76.0-beta.1

# Conflicts:
#	.github/workflows/release.yml
2026-09-13 16:31:22 +03:00
Sergey Matyunin c3a749949f ci: активировать reconciler #555 в main
Синхронизировать исполняемый process workflow с dev и добавить bounded queue reconciler в ветку по умолчанию. Product source, release candidate и version files не меняются.

Issue: #555
User-Visible: no
2026-09-13 16:25:45 +03:00
Sergey Matyunin 66bf277da2 Prepare v1.76.0-beta.1
Prepare the first beta after stable v1.75.0 from the exact integrated dev
tree. Version sources, bilingual changelogs, release notes, status and both
committed bundle snapshots move together.

Pinned local proof: Node 22.23.2 and Python 3.14.7; npm test 2618 passed,
0 failed, 1 skipped; production bundle sync and budget passed; config reload,
pan coverage, post-write adoption and summary-panel browser smokes passed;
targeted pure backend 197 passed. Bundle headroom is 12,830 B, below the
warning threshold but inside the enforced ceiling.

Issue: #541
Issue: #542
Issue: #543
Issue: #544
Issue: #545
Issue: #546
Issue: #547
Issue: #548
Issue: #549
Issue: #550
Issue: #551
Issue: #553
Issue: #554
Issue: #555
Issue: #557
Issue: #561
Issue: #562
User-Visible: yes
Release: v1.76.0-beta.1
2026-09-13 16:15:37 +03:00
claude[bot] d5e0c49198 docs: review document for #555
Issue: #555
User-Visible: no
2026-09-13 13:01:27 +00:00
Sergey Matyunin d0ecd53831 fix: сохранять диагностику при сбое восстановления review-метки (#555)
Issue: #555
User-Visible: no
2026-09-13 15:47:50 +03:00
claude[bot] e66e82fc86 docs: review document for #555
Issue: #555
User-Visible: no
2026-09-13 12:43:51 +00:00
Sergey Matyunin e3bf893e3d ci: восстанавливать потерянные запросы ревью (#555)
Issue: #555
User-Visible: no
2026-09-13 15:26:13 +03:00
claude[bot] dbd435ad1a docs: review document for #553
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Failing after 1m9s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 23s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 40s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 51s
Issue: #553
User-Visible: no
2026-09-13 12:16:40 +00:00
Sergey Matyunin f601182a90 test: ловить старую формулировку процесса через перенос строки (#553)
Issue: #553
User-Visible: no
2026-09-13 15:03:47 +03:00
claude[bot] cc66474035 docs: review document for #553
Issue: #553
User-Visible: no
2026-09-13 12:02:31 +00:00
Sergey Matyuninandclaude[bot] 55db3d4986 docs: устранить противоречия процессного канона (#553)
Issue: #553
User-Visible: no
2026-09-13 11:54:02 +00:00
claude[bot] c83f19ae52 docs: review document for #551
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 40s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 32s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Failing after 46s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 25s
Issue: #551
User-Visible: no
2026-09-13 11:52:39 +00:00
Sergey Matyunin be4b54e4d1 ci: активировать корректный output интеграции ревью (#551)
Workflow сохраняет объект вердикта и отделяет delimiter GitHub output завершающим LF.

Issue: #551
User-Visible: no
2026-09-13 14:45:17 +03:00
Sergey Matyunin 06cec9669c ci: временно вернуть process.yml main к dev для проверки (#551)
Exact-SHA Validate ветки должен пройти канонический preflight до активации исправленного workflow.

Issue: #551
User-Visible: no
2026-09-13 14:36:11 +03:00
Sergey Matyunin 62e0eff219 fix: отделить delimiter результата ревью от JSON (#551)
Issue: #551
User-Visible: no
2026-09-13 14:34:17 +03:00
Sergey Matyunin d785a4aa07 ci: исправить передачу вердикта между стадиями в main (#551)
Issue: #551
User-Visible: no
2026-09-13 14:25:02 +03:00
Sergey Matyunin 23f48829c2 fix: сохранить объект вердикта между стадиями ревью (#551)
Issue: #551
User-Visible: no
2026-09-13 14:11:42 +03:00
Sergey Matyunin 7b69726217 ci: активировать раздельные стадии ревью в main (#551)
Проверка (CI) / Классификация изменённых файлов (push) Successful in 42s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m29s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m26s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 13m8s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 17m4s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 37m1s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 3m4s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 3m7s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m2s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m1s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m16s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m7s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 2m28s
Issue: #551
User-Visible: no
2026-09-13 13:48:07 +03:00
claude[bot] d07aac3581 docs: review document for #551
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Failing after 53s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 28s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m22s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 21s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 20s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 16m12s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Successful in 4m7s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 3m13s
Проверка (CI) / Доказательство выполненных проверок (push) Failing after 17s
Issue: #551
User-Visible: no
2026-09-13 10:23:06 +00:00
Sergey Matyunin 31ef70cee6 ci: разделить стадии ревью по бюджетам (#551)
Issue: #551
User-Visible: no
2026-09-13 13:05:25 +03:00
claude[bot] 6102aabbdc docs: review document for #550
Issue: #550
User-Visible: no
2026-09-13 09:55:03 +00:00
Sergey Matyunin d89b55ccf0 test: распознавать исполнившийся subtest в outcome (#550)
Issue: #550
User-Visible: no
2026-09-13 12:42:01 +03:00
Sergey Matyunin 4d8ad3db32 test: различать исходы мутационного гейта (#550)
Issue: #550
User-Visible: no
2026-09-13 12:31:22 +03:00
Sergey Matyunin 5bda3d4393 ci: синхронизировать исправленный mutation-gate.yml с dev (#549)
Issue: #549
User-Visible: no
2026-09-13 12:18:57 +03:00
claude[bot] 43ba198d7f docs: review document for #549
Issue: #549
User-Visible: no
2026-09-13 09:17:38 +00:00
Sergey Matyunin 80e3fee527 fix(ci): запускать агрегатор из material (#549)
Issue: #549
User-Visible: no
2026-09-13 11:48:16 +03:00
Sergey Matyunin 48b9649d45 ci: синхронизировать mutation-gate.yml с dev (#549)
Issue: #549
User-Visible: no
2026-09-13 11:46:19 +03:00
claude[bot] 08701ba092 docs: review document for #549
Issue: #549
User-Visible: no
2026-09-13 08:31:21 +00:00
Sergey Matyunin 723ec6d362 ci: фиксировать material ночных мутаций (#549)
Issue: #549
User-Visible: no
2026-09-13 11:21:40 +03:00
claude[bot] 5b0cb687c9 docs: review document for #548
Issue: #548
User-Visible: no
2026-09-13 08:12:59 +00:00
Sergey Matyunin b15d357fa2 fix(ci): принимать proof reuse геометрического parity (#548)
Issue: #548
User-Visible: no
2026-09-13 10:58:19 +03:00
Sergey Matyunin 76d8017e87 ci: исполнять TS/Python parity на чистом runner (#548)
Issue: #548
User-Visible: no
2026-09-13 10:49:07 +03:00
claude[bot] e85849b86e docs: review document for #547
Issue: #547
User-Visible: no
2026-09-13 07:47:01 +00:00
Sergey Matyunin 6c6f53491f fix(release): bind beta bookkeeping to candidate (#547)
Issue: #547
User-Visible: no
2026-09-13 10:35:43 +03:00
claude[bot] f34ca4090b docs: review document for #546
Issue: #546
User-Visible: no
2026-09-13 07:35:27 +00:00
Sergey Matyunin 5fc596c748 fix(process): bind verdict wait to current review round (#546)
Issue: #546
User-Visible: no
2026-09-13 10:26:12 +03:00
claude[bot] a388243129 docs: review document for #541
Issue: #541
User-Visible: no
2026-09-13 07:25:48 +00:00
Sergey Matyunin 0fd6977b47 docs: совместить proof-контракт после ребейза (#541)
Issue: #541
User-Visible: no
2026-09-13 10:09:24 +03:00
Sergey Matyunin fdb0d0743d ci: keep proof job names API-verifiable (#541)
Issue: #541
User-Visible: no
2026-09-13 10:07:04 +03:00
Sergey Matyunin a8ae5a45e1 ci: fix proof job workflow syntax (#541)
Issue: #541
User-Visible: no
2026-09-13 10:07:04 +03:00
Sergey Matyunin 9c269c302d ci: unify Validate proof across gates (#541)
Issue: #541
User-Visible: no
2026-09-13 10:07:04 +03:00
claude[bot] 01ce4817c3 docs: review document for #562
Issue: #562
User-Visible: no
2026-09-13 07:04:49 +00:00
Sergey Matyuninandclaude[bot] d6180f7f5d fix: подсказывать infra-вход до появления ветки (#562)
Issue: #562
User-Visible: no
2026-09-13 06:56:20 +00:00
claude[bot] a66649a13b docs: review document for #562
Issue: #562
User-Visible: no
2026-09-13 06:56:20 +00:00
Sergey Matyuninandclaude[bot] 7b58f73e57 fix: доказывать infra-трек по diff (#562)
Issue: #562
User-Visible: no
2026-09-13 06:56:20 +00:00
claude[bot] f538825925 docs: review document for #562
Issue: #562
User-Visible: no
2026-09-13 06:56:20 +00:00
Sergey Matyuninandclaude[bot] 3934f8d8e5 process: отвязать роли от конкретных агентов (#562)
Issue: #562
User-Visible: no
2026-09-13 06:56:20 +00:00
claude[bot] f6e5651ccf docs: review document for #542
Issue: #542
User-Visible: no
2026-09-13 06:55:25 +00:00
Sergey Matyuninandclaude[bot] 744f502ba3 ci: include dynamic backend inputs in gates (#542)
Issue: #542
User-Visible: no
2026-09-13 06:46:38 +00:00
claude[bot] 9c08d583ab docs: review document for #557
Issue: #557
User-Visible: no
2026-09-13 06:15:46 +00:00
Sergey Matyuninandclaude[bot] c1c3743f5d infra: закрепить локальный toolchain Windows и WSL (#557)
Добавлены безопасные pinned entrypoints, вывод фактических путей, идемпотентный Windows setup и WSL verification с настоящим HA subset и Linux capture.

Issue: #557
User-Visible: no
2026-09-13 06:05:45 +00:00
Claude eb77224e0c ci: единый staged→tested→published путь установочных ассетов (#540)
`release-zip.yml` выкладывал `houseplan.zip` в ту же секунду, когда релиз
становился публичным — до Validate, Full Performance и E2E; `release.yml`
параллельно пересобирал `houseplan-card.js`, а E2E требовал публичного ZIP,
чтобы вообще начаться. Публикаторов было четыре, порядок — ни одного.

Теперь публикатор стабильных один — `release.yml`: закрепить SHA → релиз в
черновике (опубликованный руками немедленно возвращается в черновик) → гейты
на SHA (трейлер `Release: <tag>`, контракт `--stable`, Validate, Full
Performance, E2E на коммите-кандидате через tarball codeload) → одна сборка,
`git archive` ZIP из того же дерева, `SHA256SUMS` → загрузка в черновик →
публикация → скачать публичное и сверить с паспортом → анонс. Dispatch на
публичный тег — ремонт: догружается только недостающее, расходящийся хеш —
отказ. Беты кладут тот же паспорт; локальный публикатор больше не ждёт
републикаторов — их нет.

- `.github/workflows/release-zip.yml` удалён
- `scripts/release-assets.mjs` — паспорт ассетов (`sums`/`check`), чистые
  функции под юнитами
- `scripts/e2e-gate.mjs --ref=<sha>` — под тестом кандидат, `--tag` только
  для выбора `upgrade_from`
- `scripts/release-contract.mjs --stable`
- мутанты: независимый публикатор, снятая зависимость от гейта, релиз без
  возврата в черновик, `--clobber` в ремонте, E2E на теге, слепой паспорт

Issue: #540
User-Visible: no
2026-09-13 07:42:23 +03:00
claude[bot] c53f9ffc02 docs: review document for #561
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Классификация изменённых файлов (push) Successful in 15s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 32s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 14s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 35s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m5s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 6m26s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #561
User-Visible: no
2026-09-12 23:17:47 +00:00
Sergey Matyunin bb35af993a fix: сохранить identity карточек Masonry после reload (#561)
Сводная панель теперь использует канонический порядок карточек Home Assistant и не переносит локальные настройки между визуальными колонками после remount.

Issue: #561
User-Visible: yes
2026-09-13 02:04:45 +03:00
claude[bot] d6b33b115b docs: review document for #561
Issue: #561
User-Visible: no
2026-09-12 22:53:12 +00:00
claude[bot] d527ff8dcc docs: review document for #561
Issue: #561
User-Visible: no
2026-09-12 22:47:16 +00:00
claude[bot] b26d969402 docs: review document for #554
Issue: #554
User-Visible: no
2026-09-12 21:05:52 +00:00
Sergey Matyuninandclaude[bot] 51ead265f0 fix: avoid double disk reserve for staged uploads (#554)
Issue: #554
User-Visible: yes
2026-09-12 20:56:45 +00:00
claude[bot] 49130d4ffd docs: review document for #554
Issue: #554
User-Visible: no
2026-09-12 20:44:46 +00:00
claude[bot] 3aa9ffbd1a docs: review document for #545
Issue: #545
User-Visible: no
2026-09-12 20:36:47 +00:00
Sergey Matyunin 81daebd788 fix: complete radar source inventory (#545)
Issue: #545
User-Visible: yes
2026-09-12 23:19:16 +03:00
claude[bot] 036a15aade docs: review document for #545
Issue: #545
User-Visible: no
2026-09-12 20:07:39 +00:00
claude[bot] ce6650b1e0 docs: review document for #543
Issue: #543
User-Visible: no
2026-09-12 19:54:45 +00:00
Sergey Matyunin a1fac44d72 fix: не откатывать конфигурацию поздним reload (#543)
Issue: #543
User-Visible: yes
2026-09-12 22:29:16 +03:00
claude[bot] 3342910b27 docs: review document for #543
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 36s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 22s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 58s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 16s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m10s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Классификация изменённых файлов (push) Successful in 21s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Issue: #543
User-Visible: no
2026-09-12 18:57:06 +00:00
claude[bot] 4a91d5cbe8 docs: review document for #543
Issue: #543
User-Visible: no
2026-09-12 18:51:40 +00:00
claude[bot] 6d2facc2e3 docs: review document for #544
Issue: #544
User-Visible: no
2026-09-12 17:59:35 +00:00
Sergey Matyunin 6502f516f9 fix: keep live pan edges covered (#544)
Issue: #544
User-Visible: yes
2026-09-12 20:44:05 +03:00
claude[bot] 0f06b60084 docs: review document for #544
Issue: #544
User-Visible: no
2026-09-12 17:30:56 +00:00
Codex 63816a4ec3 ci: mirror announce.yml and release.yml from dev (#538 — анонс после выкладки)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 2m48s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m17s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m31s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 2m43s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m49s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m7s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m12s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 36s
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m2s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Failing after 1m1s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 28m12s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 33m37s
Оба файла живут на `main`: релизные воркфлоу запускаются событием релиза, а оно
берёт их из ветки по умолчанию. Без зеркала анонс на `main` продолжал бы висеть
на самом событии.

Issue: #538
User-Visible: no
2026-09-12 14:37:55 +03:00
Codex 66a6485418 Контракт беты пинит отсутствие ветки события, а не её наличие
`release-contract.test.mjs` требовал в анонсе условие про
`github.event_name == 'release'` — оно и было единственным местом, где путь
события закреплялся. Раз анонс вызывается только после выкладки, требование
перевёрнуто: ветки события в файле быть не должно.

Issue: #538
User-Visible: no
2026-09-12 14:02:27 +03:00
Codex 7baa1cfa13 Свидетель требует директиву, а не её цитату в комментарии
Мутант, снимающий `needs: build` с анонса, выживал: проверка искала подстроку
`needs: build` в блоке задания, а ровно эта строка процитирована двумя
строками выше — в комментарии, объясняющем, зачем зависимость нужна. Проверка
зеленела на собственном объяснении.

Issue: #538
User-Visible: no
2026-09-12 14:02:27 +03:00
Codex 293309355b Анонс уходит после выкладки ассетов, а не рядом с ней
Три воркфлоу висели на одном событии `release: published` и бежали
параллельно. Анонс выигрывал эту гонку всегда: проверять ему нечего. 12.09
стабильную v1.75.0 объявили в канале в ту же минуту, когда гейт отказал —
Full Performance был красный (#537), E2E после него не выполнялся вовсе,
ассеты не выкладывались. Подписчики получили сообщение о релизе, страница
которого осталась без `houseplan-card.js`.

Триггер события снят: у анонса остаются кнопка проверки связи и вызов из
воркфлоу. `release.yml` зовёт его после джобы выкладки (`needs: build`), то
есть красный гейт или несостоявшаяся выкладка сообщения не рождают. Путь беты
не тронут — `publish-prerelease.yml` звал анонс сам и раньше.

Мёртвая ветка чтения события из шага убрана вместе с триггером: тело берётся
из заметок ветки тега, как в вызове из беты.

Issue: #538
User-Visible: no
2026-09-12 14:02:27 +03:00
Codex 62d5446114 ci: mirror process.yml from dev (#539 — диспатч после того, как ссылка доехала)
Для события `issues` GitHub берёт workflow из ветки по умолчанию, поэтому
правка конвейера действует только из `main`.

Issue: #539
User-Visible: no
2026-09-12 13:55:00 +03:00
Codex c291384b7a Свидетель ожидания пинит саму сверку, а не её обвязку
Мутант, выкидывающий сравнение ответа REST с новой вершиной, выживал: проверка
смотрела на вызов `gh api`, токен, порядок шагов и текст отказа — всё это
мутант оставляет на месте, а цикл после него выходит на первой же итерации, и
ожидание становится декорацией.

Issue: #539
User-Visible: no
2026-09-12 13:54:38 +03:00
Codex ccfd2565a7 Диспатч уходит после того, как ссылка ветки доехала
`workflow_dispatch` в API принимает только ref: SHA туда передать нельзя, имя
ветки резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам
переписывает ветку ребейзом — и 12.09 на #536 диспатч, отправленный через три
секунды после force-push, встал на ДОпушевый SHA. Гейт искал прогон строго на
SHA материала, не нашёл и вернул задачу автору со словами «материал сменился».
Чинить было нечего: дерево задачи не менялось ни на байт, материал сдвинул сам
конвейер.

Две меры, у каждой своя роль.

Шаг ребейза не заканчивается, пока REST не отдаст новую вершину — именно REST,
потому что через него же идёт диспатч. Минута ожидания, после чего отказ, а не
молчание: диспатч на устаревший SHA стоит трёх минут гейта и потерянного
захода.

Гейт, не дождавшись прогона на материале и увидев на ветке диспатч на другом
SHA, сначала пробует запустить ещё раз. Своя гонка этим закрывается, чужой
коммит переживает и вторую попытку, а формулировка отказа больше не называет
сменой материала то, что ею не является.

Issue: #539
User-Visible: no
2026-09-12 13:54:03 +03:00
Codex b53046b8e1 Догнать main после #537
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m46s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 2m55s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 2m57s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m41s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 2m45s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m49s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m47s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 20s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m23s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 47s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 26m7s
Инвариант графа панели из #535 предъявлялся чужому манифесту, и кандидат
отказывался грузить любую базу старше себя: девять профилей Full Performance
падали на одном шаге, гейт стабильного релиза не выдал `houseplan-card.js`
для v1.75.0. #537 развёл вопрос загрузчика и топологию собственной сборки.

Issue: #537
User-Visible: no
2026-09-12 12:40:19 +03:00
Claude 1ef35883e0 Ask a foreign manifest whether it can be loaded, not how it was wired
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 52s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 27s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 21s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m10s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 13m27s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 15m22s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The performance harness runs the candidate's benchmark against a
baseline checkout, so the candidate's validator reads a manifest built
by an older commit. #535 put a rule about the CURRENT build into that
shared validator — the panel graph must not contain the card facade —
and it is false of every build before #535 by construction. The
candidate then refused to load any older baseline: all nine performance
profiles went red at once on the same step, the stable release gate
withheld `houseplan-card.js` from the published v1.75.0, and none of it
was about speed.

assertBundleManifest now answers only the loader's question: paths
exist, nothing is duplicated, graphs reference listed assets, sizes add
up. The topology of the current build moves to assertOwnBundleTopology,
called from bundle-sync.mjs, which materializes our own dist, and from
the unit test that reads dist/houseplan-assets.json. Neither ever looks
at a foreign tree.

Reproduced end to end, not only in a unit: a v1.74.0 worktree built with
its own code, then `node demo/benchmark_large_house.mjs
--target-root=<baseline>` from this tree. Before the change it stops
with «initial panel graph must contain its own stable entry only»; after
it, the profile is captured.

Issue: #537
User-Visible: no
2026-09-12 09:39:05 +03:00
Claude 2c6410bb87 Release v1.75.0
Promotion-only on top of v1.75.0-beta.1: seven version sources, the
generated bundle snapshots and the release metadata. No product source
code moves in this commit.

The line aggregates from the stable v1.74.0 and carries one user-visible
fix. The House Plan sidebar page could serve a previous version of the
card for hours: the panel entry fetched it through `./houseplan-card.js`,
a relative specifier, and relative resolution does not inherit the `?v=`
a dashboard gets from its Lovelace resource, while the entry files carry
no Cache-Control at all. The panel now imports the implementation by its
content-hashed name, so either the matching card arrives or the panel
says out loud that the page is stale (#535). Internal in the line: #536.

Known contradiction, recorded rather than silenced: the release contract
in scripts/release-contract.mjs requires the grouped "small fixes"
bullet unconditionally, while `npm run release:notes -- v1.75.0
--verify` rejects it because every user-visible issue of the range is
already itemised. The two rules deadlock any stable with a single
user-visible issue. The bullet stays, because the contract is the
automated gate that Validate enforces; the verifier's objection is
written down in STATUS and will get its own issue.

npm test 2532/2531/0 fail, release contract green on all seven sources,
docs strict green.

Issue: #535
User-Visible: yes
Release: v1.75.0
2026-09-12 09:02:29 +03:00
Claude e921d5906d Prepare v1.75.0-beta.1
Seven version sources move together to 1.75.0-beta.1, both changelogs
close their section over what has landed since the stable v1.74.0, the
release notes carry the one shipped bullet on each side with the grouped
small-fixes bullet last, and STATUS says what this beta is.

What the user gets: the House Plan sidebar page can no longer serve a
previous version of the card. The panel entry fetched it through
`./houseplan-card.js` — a relative specifier, and relative resolution
does not inherit the `?v=` a dashboard gets from its Lovelace resource,
while the entry files carry no Cache-Control at all. A browser was free
to answer from its own heuristic cache for hours, and a stale loader
named a stale immutable chunk, so the panel ran an old card against the
current backend with nothing but the version banner to show for it —
and reloading could not help, because the address never changed (#535).

Internal in the line: #536 — the version banner now asks the host to
repaint when it drops the notice on disconnect, instead of leaving its
removal to whatever unrelated update happened to run next.

npm test 2532/2530/0 fail (the #349 manifest-tracking check goes green
with this commit), release contract green on all seven sources, bundle
287 323 B gzip inside the ceiling, docs strict green.

The `Release:` trailer is what asks CI for the heavy gates — smokes,
golden and the full performance comparison — on this exact SHA (#479).

Issue: #535
User-Visible: yes
Release: v1.75.0-beta.1
2026-09-12 07:18:21 +03:00
claude[bot] bb031d8599 docs: review document for #536
Issue: #536
User-Visible: no
2026-09-12 04:06:12 +00:00
Claudeandclaude[bot] 1e1880f010 Tell the host when the version notice goes away with the element
The controller dropped its banner on disconnect without asking the host
to repaint. Lit renders neither on disconnect nor on reconnect, so the
markup produced before the detach outlived it: the notice stayed on
screen while the controller no longer owned one, and it left only when
some unrelated update happened to run. Correctness rested on a
coincidence.

Measured on the built module with a counter: the sequence mismatch ->
disconnect -> versions agree -> connect asked for exactly one repaint,
the one that showed the notice. It now asks for two, and the second is
the one that takes the notice away.

Nothing else about the teardown changes. The field is still cleared
because a detached element cannot deliver animationend, reconnect still
rebuilds the notice from the retained input, and a disconnect with no
notice still asks for nothing.

Issue: #536
User-Visible: no
2026-09-12 03:49:46 +00:00
claude[bot] 4906f0a97f docs: review document for #536
Issue: #536
User-Visible: no
2026-09-12 03:40:45 +00:00
claude[bot] 05a2c68da2 docs: review document for #535
Проверка (CI) / Классификация изменённых файлов (push) Successful in 27s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 53s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m16s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 21s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 55s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m10s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #535
User-Visible: no
2026-09-11 22:42:21 +00:00
Claudeandclaude[bot] 063de0eef4 Let the panel reach the card by its hashed name, not by the one address without a version
The sidebar page could serve a previous card for hours. It imported the
card through the stable facade, `./houseplan-card.js` — a relative
specifier, and relative resolution does not inherit a query. A dashboard
reaches the same file as `houseplan-card.js?v=1.74.0`, so an upgrade
changes its URL and the browser must refetch. The panel always asked for
the same address, and entries are served with no Cache-Control at all —
only ETag and Last-Modified — so the browser applies heuristic freshness
and may answer from cache without asking. A stale 1164-byte loader names
a stale chunk, chunks are immutable for a year, and the panel then ran a
previous card against the current backend without a single error. The
version banner was telling the truth; reloading could not help, because
the address never changed.

Rollup already emits the right edge: the panel's side-effect import
points straight at the shared implementation. The rewrite in
entryFallbackPlugin replaced it with the facade for a fallback that the
hashed name gives anyway — and better: a chunk the manifest no longer
serves now raises the panel's own "House Plan was updated" notice
instead of silently working on old code.

Two #486 assertions change meaning and are rewritten, not adjusted: the
panel no longer routes through the facade, and its initial graph no
longer contains it. The invariant they defended — the panel reuses the
exact card graph, never a second copy — is now stated over the
implementation, and a new test pins that no built entry reaches the card
by an address without a version.

Issue: #535
User-Visible: yes
2026-09-11 22:12:11 +00:00
claude[bot] a52dcab77a docs: review document for #535
Issue: #535
User-Visible: no
2026-09-11 21:42:04 +00:00
Codex 1ca3b5fae3 Записать #532 и #534 в раздел v1.74.0, а не в «не выпущено»
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 2m56s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m54s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m11s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m9s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m15s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 4m48s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 3m35s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 2m0s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 3m20s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m7s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 30m30s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 33m30s
Обе задачи вошли в стабильный тег кодом, но их строки чейнджлога остались под
«Не выпущено»: #532 и #534 закрывались в тот же день, что и сам выпуск, и
секция версии к тому моменту уже была закрыта. Тело релиза при этом не называло
ни ту, ни другую — обе молча попали под «мелкие исправления и улучшения», хотя
#532 это как раз та правка, ради которой владелец и присылал три профиля
Firefox.

Строки перенесены в секцию v1.74.0, тело релиза переписано: первый пункт теперь
называет все четыре правки скорости (#532, #531, #524, #534). Проверка правил
#328 зелёная на диапазоне v1.73.0..v1.74.0.

Тег не двигается: он указывает на протестированное дерево, а тело релиза на
GitHub обновляется отдельно — именно его читает человек.

Issue: #534
User-Visible: no
2026-09-12 00:17:21 +03:00
Codex e63460f068 Принять четыре эталона дневного цикла, которые изменил #532
К2 задачи #532 назвала это заранее: промоушен поверхности контура в свой
композиционный слой делит SVG надвое, оставшееся содержимое ложится на другую
субпиксельную сетку, и диагональная штриховка стен сглаживается иначе. Кадры
полагалось пересмотреть на кандидате — они дожили до кандидата стабильного,
потому что golden-джоба гоняется только на дереве с трейлером `Release:`, а
мерж #532 такого трейлера не нёс.

Проверено по критериям AC3 самой #532: разошлись **ровно четыре** кадра
`day-cycle-{dawn,day,dusk,night}-dark`, средний цвет кадра сдвинулся на
0,001–0,080 из 255 при пороге 0,1, средний знаковый сдвиг на разошедшихся
пикселях от −0,19 до +0,87. Кадры просмотрены глазами: отличается только
сглаживание штриховки стен, палитра, геометрия, подписи и ореол те же.

Приняты из артефакта полного прогона Linux CI на этом же дереве, остальные
165 сцен сохранены без изменений, свидетелей среды 129.

Issue: #532
User-Visible: no
Release: v1.74.0
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34641155082
2026-09-11 23:05:44 +03:00
Codex ac1e69be7f Release v1.74.0 — слой пространства с ключом
Догоняем main после #534: смена пространства больше не сверяет двести
маркеров поэлементно, чтобы затем всё выбросить.

Issue: #534
User-Visible: no
Release: v1.74.0
2026-09-11 22:52:06 +03:00
claude[bot] 5f0dfa84e1 docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 19:51:30 +00:00
Codex 5afdb644c7 Смена пространства выбрасывает слой, а не сверяет его поэлементно
#525 увёл оба списка сцены с `map()` на `repeat(items, (item) => item.id, …)`,
чтобы Lit не переиспользовал узлы по позиции и не проигрывал анимацию двери,
которой не было. Правка верная, но на плане с двумя сотнями маркеров она
оказалась дорогой ровно там, где пользы не приносит: при смене пространства
ключи не пересекаются вовсе, и `repeat` строит две карты ключей и обходит оба
списка, чтобы затем всё равно выбросить всё и создать заново.

Бисект по медиане `switchCycleMs` на `large-house`: 871,2 перед #525 → 953,5
после. На раннере эти 80 мс распадаются на шесть-девять дополнительных длинных
задач, и `longTask.countP95` вышел за порог стабильного гейта.

Теперь оба списка рендерятся как `keyed(space.id, repeat(…))`. Внешний ключ
делает смену пространства: поддерево выбрасывается целиком, дифа нет. Внутренний
остаётся, потому что состав списков едет и внутри пространства — у маркеров от
призраков редактора и живого синка, у проёмов от записи с нерешённым хостом,
которая живёт только в режиме plan, — а переходы на `.device-shell-frame`,
`.op-leaf` и `.op-arc` никуда не делись.

Замер после правки: `switchCycleMs` 889,1 против 936,0 на `main` и 871,2 до
#525. Потолок карточки поднят на одну строку — на `import { keyed }`;
переносить нечего, сам рендер не вырос ни на символ.

Issue: #534
User-Visible: yes
2026-09-11 22:30:11 +03:00
claude[bot] a9a6d37bba docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 19:14:21 +00:00
claude[bot] 1fb84755c2 docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 19:07:13 +00:00
claude[bot] e40d3f18d2 docs: review document for #534
Issue: #534
User-Visible: no
2026-09-11 18:53:32 +00:00
Codex 8f68cb51bd Release v1.74.0
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 2m24s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 2m30s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m19s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m8s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m2s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m16s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 2m16s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m41s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 3m24s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 22s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 59s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Successful in 2m31s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Successful in 3m33s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Successful in 3m30s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Successful in 2m40s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Successful in 2m42s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Successful in 2m39s
Слияние протестированного dev (7a81d41b) в main. Собственные CI-коммиты main
сохраняются: у main свой validate.yml, зеркалятся только process.yml и
mutation-gate.yml.

Issue: #533
User-Visible: no
Release: v1.74.0
2026-09-11 20:25:15 +03:00
claude[bot] 41ae5e9986 docs: review document for #532
Проверка (CI) / Классификация изменённых файлов (push) Successful in 52s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m29s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 19s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 15s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m44s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 7m45s
Issue: #532
User-Visible: no
2026-09-11 17:12:06 +00:00
Claudeandclaude[bot] 1cfe7fa53c Say what the day-cycle layer change does to the picture, and where the ratio was measured
Code review r1 found two documentation claims that the task's own contract
contradicts.

Both changelogs promised the picture was unchanged "to the pixel". It is
not: splitting the layer moves the wall hatch by a sub-pixel, and that is
exactly why four day-cycle baselines are re-taken. The entry now says what
a reader can check — the outline around the plan matches pixel for pixel,
the hatch inside the walls is anti-aliased a shade differently.

docs/SUN.md credited Firefox with the fifteen-fold ratio. That number comes
from headless Chromium through CDP tracing; the Firefox profile that opened
the issue measured the same cause differently — 23 MB of texture uploads per
frame and about nine frames per second. Each number now names its engine.

Issue: #532
User-Visible: no
2026-09-11 17:01:04 +00:00
claude[bot] 3322c20c09 docs: review document for #532
Issue: #532
User-Visible: no
2026-09-11 17:01:04 +00:00
Claudeandclaude[bot] ced9b41642 Keep the day-cycle outline on its own compositing layer
The "Follows the sun" background made the plan crawl in Firefox: 23 MB of
textures per frame, sixteen of about twenty picture-cache tiles thrown
away every frame, nine frames per second. The card's own JavaScript was
idle for 89 % of that.

The cost is the outer outline. It is a triple drop-shadow over the
grouped paper footprint, and although the group holds paper silhouettes
only and never changes on hover or pan, the filter lived in the plan's
own layer — so every repaint of the plan re-ran three blur passes over
the whole sheet. One hint moves the filtered paper into its own layer
and unhooks it from the plan's repaints.

Measured on a demo-stand pan (Chromium, CDP, summed RasterTask): 1456.6
ms against a static background's 96.7 ms before, 84.7 ms against 103.2
ms after. The new smoke measures that ratio and fails above two.

The picture does not change: the outline outside the plan matches byte
for byte and the frame's mean colour moves from 176.59 to 176.66 of 255.
Splitting the layer does move the remaining content onto a different
sub-pixel grid, so the diagonal wall hatch anti-aliases differently and
the four day-cycle baselines are re-taken on the beta candidate.

Issue: #532
User-Visible: yes
2026-09-11 17:01:04 +00:00
Codex 7a81d41bab Кандидат v1.74.0 после починки свидетеля
Первый кандидат держал не дефект продукта, а мигающий свидетель ресайза: он
фиксировал экранные координаты заранее, а карточка переводит их обратно в момент
события. Свидетель починен (#533), содержание релиза не изменилось.

Трейлер `Release:` просит CI прогнать тяжёлые гейты — смоки, golden и полное
сравнение производительности — на этом SHA.

Issue: #533
User-Visible: no
Release: v1.74.0
2026-09-11 19:56:20 +03:00
claude[bot] 590f5ab127 docs: review document for #533
Issue: #533
User-Visible: no
2026-09-11 16:55:33 +00:00
Codex 11ccd5b095 Ревью r2: лейблы доставки называют свой сценарий
Medium: две обёртки унаследовали имя соседа. Блок `aria-disabled`-ручки звался
`owner_boundary`, а блок с проверками `owner_boundary_*` — `range_role`, имени,
которого нет ни у одной проверки. Логика при этом верна, страдает ровно то,
ради чего правка и делалась: чтение красной строки с раннера.

Теперь `disabled.*` и `owner_boundary.*` стоят на своих блоках. Смок зелёный.

Issue: #533
User-Visible: no
2026-09-11 19:46:29 +03:00
claude[bot] e09bb2faee docs: review document for #533
Issue: #533
User-Visible: no
2026-09-11 16:44:22 +00:00
Codex 73c13e37b4 Ревью r1: доставка проверяется у всех событий смока
Medium: обёрнуты были три вызова из двадцати двух — главный сценарий. Остальные
девятнадцать по-прежнему молча отбрасывали возвращаемое значение, и жест,
не доехавший до ручки, оставлял смок зелёным.

Теперь `sent()` стоит на каждом вызове, имя проверки называет сценарий и тип
события. Отрицательный прогон: подмена `cx` на несуществующую ручку в
`mixed_role` красит ровно `mixed_role.pointerdown_sent`.

Issue: #533
User-Visible: no
2026-09-11 19:33:53 +03:00
claude[bot] e74823737f docs: review document for #533
Issue: #533
User-Visible: no
2026-09-11 16:31:13 +00:00
Codexandclaude[bot] acea695069 Свидетель ресайза меряет то же и тогда же, что и карточка
Смок считал экранные точки один раз, заранее, а карточка переводит их обратно в
момент события — от текущего размера стейджа и текущего вида. Стоило раскладке
осесть между замером и жестом, и 34 экранных пикселя превращались уже не в 50
единиц плана: ресайз коммитил не ту величину, а свидетель сообщал об этом
четырьмя немыми `expected true, got false`. Раннер это ловил, локальная машина —
нет, и красный гейт закрыл выпуск v1.74.0.

Теперь координаты передаются в единицах плана, а перевод живёт внутри того же
кадра, что и отправка события. Доставка события проверяется (прежде
возвращаемое значение хелпера для `pointermove` игнорировалось молча), а
устойчивость отображения — отдельной проверкой `safe_resize.mapping_stable`:
масштаб на захвате и на движении обязан совпасть, иначе в имени проверки
печатаются оба масштаба и оба размера стейджа.

Продуктовый код не тронут: ни один из экспериментов не указал на дефект
ресайза. Если `mapping_stable` когда-нибудь покраснеет на раннере — это и будет
доказательством обратного, с числами в первой же строке.

Issue: #533
User-Visible: no
2026-09-11 16:18:55 +00:00
claude[bot] 9584933c98 docs: review document for #532
Issue: #532
User-Visible: no
2026-09-11 16:05:14 +00:00
claude[bot] 8afba7e6ed docs: review document for #532
Issue: #532
User-Visible: no
2026-09-11 15:31:27 +00:00
Codex 116cfd5fef Prepare v1.74.0
Seven version sources move together to 1.74.0; both changelogs gain the
stable section aggregated from the previous stable v1.73.0, the release
notes carry three itemised bullets on each side plus the grouped
small-fixes bullet, and STATUS says what this release is.

What the user gets since v1.73.0: the plan is noticeably smoother and in
Firefox dramatically so — a pan frame now moves what is already drawn by
a composited transform with the scene re-rasterizing on a budget (#531),
and a marker shadow sized in container units no longer restarts a
non-composited animation on every marker at once (#524); the printed plan
is about a third larger, because the in-plan dimension labels shrank and
the separate column of external dimensions is gone (#530); a plan left
over from the era of rooms without walls can be edited again, the
leftover cleaned up on the first save instead of refusing every change
(#529, reported in #527). Grouped under small fixes: alignment guides
follow the gesture again (#521), the summary panel neither freezes the
first frame nor claims an unavailable source (#509) and its settings
dialog scrolls again (#508), a space switch no longer replays a
neighbouring door's leaf (#525), and the card downloads 12.8 KB less on a
cold start (#526).

The speed verdict for #531 is deliberately not claimed here: headless
Firefox and Chromium hold 60 fps on both the old and the new path, so it
is owed by a profile from the owner's machine on the same pan.

`npm test` 2528/0/1 skip, `pytest tests_backend` 467 passed, release
notes pass the #328 verifier against v1.73.0..HEAD, bundle 287 242 B gzip
inside the ceiling, docs strict green.

Issue: #531
User-Visible: yes
Release: v1.74.0
2026-09-11 17:25:52 +03:00
Codex f65d07e3ea Принять три PDF-эталона, которые изменил #530
Golden-джоба запускается только на кандидате с трейлером `Release:`, поэтому
Validate на мерже #530 её не гонял, и первый же релизный прогон
v1.74.0-beta.3 показал расхождение ровно в трёх сценах экспорта PDF:
`pdf-export-geometry-light`, `pdf-export-polish-light`,
`pdf-export-stepped-dimensions-light`.

Расхождение — это и есть предмет #530: план на листе крупнее, подписи
размеров стоят на чертеже, столбца выносов сбоку больше нет
(`sceneCoverage` 0.577). Кадры приняты с явным `--expect-change` из
артефакта того самого прогона (отпечаток исходников совпадает),
остальные 166 сцен сохранены без изменений, свидетелей среды 133.

Issue: #530
User-Visible: no
Release: v1.74.0-beta.3
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34597337319
2026-09-11 15:28:34 +03:00
Codex 45d44608a1 Prepare v1.74.0-beta.3
Seven version sources move together to 1.74.0-beta.3; the changelog
sections in both languages close over what has landed since beta.2, the
release notes carry the two shipped bullets on each side with the grouped
small-fixes bullet last, and STATUS says what this beta is.

What the user gets: dragging the plan is smooth again — a gesture frame
now moves what is already drawn by a composited transform, and the scene
re-rasterizes on a budget (100 ms, or a 15 % shift) instead of on every
frame, which on the owner's 144 Hz Firefox cost 200 ms per frame with the
refresh driver skipping 124-144 ticks a second waiting for paint (#531);
and the printed plan is about a third larger, because the in-plan
dimension labels shrank by a quarter and the separate column of external
dimensions is gone, so the drawing keeps a whole scale step (#530).

The speed verdict for #531 is deliberately not claimed here: headless
Firefox and Chromium hold 60 fps on both the old and the new path, so it
is owed by a profile from the owner's machine on the same pan.

`npm test` 2528/0/1 skip, release contract green on all seven sources,
bundle 287 238 B gzip inside the ceiling, docs strict green.

The `Release:` trailer is what asks CI for the heavy gates — smokes,
golden and the full performance comparison — on this exact SHA (#479).

Issue: #531
User-Visible: yes
Release: v1.74.0-beta.3
2026-09-11 15:06:32 +03:00
claude[bot] 0d63529e90 docs: review document for #531
Issue: #531
User-Visible: no
2026-09-11 11:55:24 +00:00
Codex 77a3d2607c Пересборка бандла и отпечатка доков после ребейза на dev
Ребейз на `dev` (#530) обнулил обе производные копии: хэши чанков считаются от
содержимого, а отпечаток скриншотов — от `src/**`. Пересобрано, отпечаток
принят с `--identical`: все 11 кадров попиксельно совпали.

Issue: #531
User-Visible: no
2026-09-11 14:25:35 +03:00
Codex 9d1ba1103c Ревью r1: отпечаток доков и изометрический кадр в юните
Medium: `check-docs` красный — любая правка `src/**` делает отпечаток
скриншотов стухшим (#479), а `docs`-job на обычном push идёт в режиме `warn`.
Все 11 кадров попиксельно совпали с закоммиченными, принят только отпечаток
исходников.

Наблюдение без правки закрыто заодно: во всех прежних кадрах свидетелей
`floor === view`, то есть изометрия, ради которой камера и пол проецируются
раздельно, не была тронута ничем. Новый юнит разводит виды и по сдвигу, и по
размеру.

Issue: #531
User-Visible: no
2026-09-11 14:23:53 +03:00
claude[bot]andCodex af3be59a2c docs: review document for #531
Issue: #531
User-Visible: no
2026-09-11 14:23:48 +03:00
Codex 58e31668c9 Панорама двигает сцену трансформом, viewBox пишется по бюджету
Перезапись `viewBox` — это не сдвиг, а инвалидация растеризации всей сцены.
Кадр жеста делал её каждый раз: в профиле владельца (Firefox 155, 144 Гц) кадр
доезжал до экрана 200 мс, а драйвер пропускал 124–144 тика в секунду с пометкой
«ждём краску».

Теперь `paintLiveViewport` держит якорь — кадр, чей `viewBox` записан в DOM, и
момент записи. Кадр жеста двигает узлы сцены тем же проективным преобразованием,
которым уже двигались HTML-слои, а `viewBox` переписывается по бюджету: 100 мс
либо 15 % сдвига/масштаба. Ни атрибут, ни стиль не пишутся, если строка не
изменилась.

Issue: #531
User-Visible: yes
2026-09-11 14:23:48 +03:00
claude[bot] 9d35ab35ac docs: review document for #530
Issue: #530
User-Visible: no
2026-09-11 11:06:47 +00:00
Claudeandclaude[bot] 2e26116bbb Print the plan a third larger and drop the numbered dimension column
The A4 export spent a whole step of the scale series on the "Internal
dimensions" column beside the drawing: a ten-metre house printed 1:100
landscape on a quarter of the sheet while the same plan fits 1:75
upright. The column bought little — "R1" takes as much room beside the
wall as "2.31m" does — and it was read separately from the drawing.

The column is gone. A value with no free lane beside its own wall is
simply not printed; the rectangular rule became the general one. In-plan
type drops to three quarters of its former size (dimensions 5.25 pt,
areas 6 pt, names 6.75/5.25 pt) so the larger drawing is not crowded by
labels that do not scale with it.

On the owner's file the sheet goes from 1:100 landscape to 1:75 upright
and prints 37 values inside the plan against 35 before; the cost is
about seven short values (0.41-1.13 m) that no longer appear anywhere.

Issue: #530
User-Visible: yes
2026-09-11 10:40:05 +00:00
claude[bot] d21797dc66 docs: review document for #531
Проверка (CI) / Классификация изменённых файлов (push) Successful in 26s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 52s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 25s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m59s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 7m4s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #531
User-Visible: no
2026-09-11 10:02:27 +00:00
claude[bot] b4a16ad704 docs: review document for #531
Issue: #531
User-Visible: no
2026-09-11 09:55:36 +00:00
claude[bot] a261e54ce7 docs: review document for #530
Issue: #530
User-Visible: no
2026-09-11 09:54:24 +00:00
claude[bot] af2081cbc4 docs: review document for #530
Issue: #530
User-Visible: no
2026-09-11 09:46:23 +00:00
Codex 8f5a08a609 Prepare v1.74.0-beta.2
Seven version sources move together to 1.74.0-beta.2; the changelog
sections in both languages close over what has landed since beta.1, the
release notes carry four bullets on each side with the grouped
small-fixes bullet last, and STATUS says what this beta is.

What the user gets: a plan left over from the era of rooms without walls
can be edited again — the leftover is cleaned up on the first save
instead of refusing every change, pointing at an Optimize that cannot
help and blocking the export too (#529, reported in #527); the plan is
smooth again in Firefox, where a marker shadow sized in container units
restarted a non-composited animation on all markers at once and cost 9.4
frames per second (#524); switching spaces no longer replays a
neighbouring door or marker (#525); and the card downloads 12.8 KB less
on a cold start, because the stylesheet minifier had never actually run
(#526).

Internal in the same line: the space-switch witness judges node identity
instead of a transition that #524 removed (#528), and the review gate
charges a round only for what the round changed (#518).

`npm test` 2520/0/1 skip, `pytest tests_backend` 467 passed, release
contract green on all seven sources, bundle 287 286 B gzip inside the
ceiling, docs strict green.

The `Release:` trailer is what asks CI for the heavy gates — smokes,
golden and the full performance comparison — on this exact SHA (#479).

Issue: #529
User-Visible: yes
Release: v1.74.0-beta.2
2026-09-11 11:49:16 +03:00
claude[bot] db39b620ba docs: review document for #529
Issue: #529
User-Visible: no
2026-09-11 08:40:14 +00:00
Codex 85e5d61fb1 docs: refresh the screenshot source fingerprint after the room-drafts fix
All 11 frames are pixel-identical (docs:accept --identical, #512).

Issue: #529
User-Visible: no
2026-09-11 11:19:59 +03:00
Codex 49912ac989 fix(plan): legacy room drafts are healed, not a locked door
A plan that still carried a `room_drafts` key while already on the
current wall model could not be edited at all. The card mirrors the same
migration, so a structural edit was refused before the request ever left
the browser; the toast sent the user to "Optimize plans", which reports
that everything is already optimal because it looks at something else
entirely; and the export path calls the same migration, so the one way
out — take a backup, fix the file by hand — was shut too. An empty
`room_drafts: []`, carrying no data at all, was enough to do it.

The carrier is now removed the way the first migration removes it: an
empty key silently, drafts converted one for one into partitions. The
#478 protection against a stale client re-adding the carrier moves to
the layer that can actually tell the two apart —
`validate_wall_model_transition` sees both the submission and the stored
plan, and refuses when the drafts appear over a plan that does not have
them. It no longer keys on the submitted model number: a stale card
echoes back the number it was given, which is exactly how the outdated
client slipped past this guard and met "conflicting wall identifiers"
instead of "update the card and reload the page". The schema invariant
keeps refusing a non-empty carrier as the last line.

Both mirrors change together and stay identical; the parity fixture is
untouched.

Issue: #529
User-Visible: yes
2026-09-11 11:19:04 +03:00
claude[bot] b736878c0b docs: review document for #529
Issue: #529
User-Visible: no
2026-09-11 08:13:43 +00:00
claude[bot] 2977177283 docs: review document for #529
Issue: #529
User-Visible: no
2026-09-11 08:06:11 +00:00
claude[bot] e408b6fb70 docs: review document for #526
Issue: #526
User-Visible: no
2026-09-11 07:32:32 +00:00
Codex bde05697d1 docs: refresh the screenshot source fingerprint after the minifier fix
All 11 frames are pixel-identical (docs:accept --identical, #512): the
minified stylesheets render the same, which is the point.

Issue: #526
User-Visible: no
2026-09-11 10:12:13 +03:00
Codex b9e40aebc2 fix(mutants): a multi-line anchor is built from escaped parts
The registry lives in a JavaScript file, so an anchor that contains a
newline has to be written with escapes; pasting the real line break
broke the module and --check reported nothing while exiting non-zero.

Issue: #526
User-Visible: no
2026-09-11 10:12:13 +03:00
Codex 0a0a9f0f4c perf(build): the stylesheet minifier had never run
It looked for the tag written as `css` immediately followed by a
backtick. The plugin is a Rollup transform, so the module has already
been through TypeScript by the time it arrives, and the TS printer puts
a space there: `css `. The guard therefore returned null for every
stylesheet in the project, and minification never ran once — around
23 KB of explanatory comments went to every user in every release.

Matching the tag as a word with optional whitespace turns it on:

  chunk, raw       1 079 508 -> 1 021 115 B   (-58 393)
  initial view     300 111 -> 287 284 B gzip  (-12 816)
  room to the budget   955 -> 13 782 B

The ceiling moves down with the fact, as the tool asks when a graph
shrinks past the band.

The risk is not the two lines; it is that 23 KB of CSS is minified for
the first time. Two witnesses cover it: a browser smoke that puts the
original and the minified text into separate stylesheets and compares
the serialised rules — 1 049 of them, identical up to the whitespace
policy the minifier declares — and a test that takes real comment text
out of src/styles and requires it to be absent from dist, so a plugin
that silently stops working cannot pass again.

Issue: #526
User-Visible: yes
2026-09-11 10:12:13 +03:00
claude[bot] 7e39c3715c docs: review document for #528
Issue: #528
User-Visible: no
2026-09-11 07:09:35 +00:00
Codex f37ccdcebc test: judge marker reuse by node identity, not by a transition
The marker half of the space-switch witness asked whether a box-shadow
transition was running on the shell. That worked only because such a
transition existed; #524 removed it — the shadow is sized in container
units and animating it cost a real user 9.4 frames per second — and the
check became trivially true. The mutant that removes the keys from the
marker list has been surviving ever since, and nobody noticed until the
next gate ran it.

The witness now keeps references to the marker nodes and requires that
none of them stays in the DOM under a different data-id after the
switch. Node identity is what the keys are for, and it does not depend
on any stylesheet.

The door half is untouched: there the transition is part of the product
contract, not a side effect.

Issue: #528
User-Visible: no
2026-09-11 10:01:46 +03:00
claude[bot] 78bc76073e docs: review document for #526
Issue: #526
User-Visible: no
2026-09-11 06:23:19 +00:00
claude[bot] 4451cf50d9 docs: review document for #526
Issue: #526
User-Visible: no
2026-09-11 06:18:52 +00:00
claude[bot] 9609040d31 docs: review document for #524
Issue: #524
User-Visible: no
2026-09-11 05:54:09 +00:00
Codex 9ad8b1f710 docs: refresh the screenshot source fingerprint after the CSS note trim
All 11 frames are pixel-identical (docs:accept --identical, #512).

Issue: #524
User-Visible: no
2026-09-11 08:34:23 +03:00
Codex b59e245e32 perf(markers): keep the CSS note short — comments ship to the browser
The long explanation inside the css`` template cost 518 gzipped bytes in
the initial chunk: the template minifier leaves comments in this file, so
every character of them is downloaded by every user. The reasoning now
lives in docs/DEVELOPMENT.md and in the commit above; the stylesheet
keeps one line and the issue number.

Measured: 300 577 B with the long notes, 300 111 B with the short ones,
300 059 B on dev before the fix.

Issue: #524
User-Visible: no
2026-09-11 08:33:18 +03:00
Codex cd47a59d3f docs: refresh the screenshot source fingerprint after the marker shadow fix
All 11 frames are pixel-identical (docs:accept --identical, #512); only
the source fingerprint moved with src/**.

Issue: #524
User-Visible: no
2026-09-11 08:29:57 +03:00
Codex 6b64801603 perf(markers): a marker's shadow no longer animates
The shadow of a device marker is sized from the marker, and the marker is
sized from the container: --device-shell-shadow is expressed in
--dev-size, which resolves to 2.5cqw. With box-shadow in the transition
list, every container-query re-evaluation — a tooltip, a scrollbar, a
rotation — produced a new computed value and restarted a 150 ms
non-composited transition on every marker at once.

The owner's Firefox profile shows what that costs: 244 box-shadow
transitions, all on span.device-shell-frame, all oncompositor:false, in
bursts of exactly 61 (the markers on screen), four bursts in two
seconds. During them the tab presented 103 frames in 11 seconds — 9.4
per second, CONTENT_FRAME_TIME median 149 ms and up to 320 — while the
refresh driver waited for paint 381 times. Our JavaScript in the worst
three seconds: 16 ms. Chromium starts the same transitions (measured:
one pixel of container width starts two per marker in both engines) and
merely pays less for them, which is why this hid there.

The shadow itself is unchanged; it simply applies at once. The core
keeps the same treatment, so the selection and focus rings appear
without a fade — an instant ring is ordinary feedback, a faded one costs
a full-frame repaint per marker. Hover still animates border-color.

Issue: #524
User-Visible: yes
2026-09-11 08:28:49 +03:00
claude[bot] f4625dd4fb docs: review document for #524
Issue: #524
User-Visible: no
2026-09-11 05:04:43 +00:00
claude[bot] 8ba87b3ff7 docs: review document for #525
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m47s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 2m51s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m23s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 1m44s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 2m14s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m45s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m4s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #525
User-Visible: no
2026-09-11 02:00:05 +00:00
Claudeandclaude[bot] ab296302d1 test: register the space-switch witness for the two renderers (#525)
`smoke-select` answered НЕОПРЕДЕЛЁННОСТЬ on this diff: the smoke does not
name `_renderDevice` or `_renderOpenings` anywhere — it switches spaces the
ordinary way and reads running transitions off the nodes those renderers
produced. That is exactly the case `scripts/smoke-links.mjs` exists for, and
without the record the next edit to either renderer selects no witness at all.

Issue: #525
User-Visible: no
2026-09-11 01:42:46 +00:00
Claudeandclaude[bot] 8d5bade656 docs: refresh the screenshot source fingerprint (#525)
The fix touches `src/houseplan-card.ts` and `src/styles/plan.styles.ts`, and
the documentation fingerprint covers the whole `src/**` corpus.

Accepted with `npm run docs:accept -- --identical` (#512): all 11 frames were
re-captured locally and matched the committed ones byte for byte, so only the
fingerprint moves, `7a90ca04 → cd8e9598`. No PNG changes. Environment: Linux,
Chromium 152 — the canon platform of #455.

Issue: #525
User-Visible: no
2026-09-11 01:42:46 +00:00
Claudeandclaude[bot] 9edef041a9 fix: openings and markers keep their identity across a space switch (#525)
Lit reuses list nodes by position. The opening list and the device markers had
no keys, so on a space switch the leaf that held a slot kept its DOM node and
only changed values — and `.op-leaf` (transform) and `.op-arc`
(stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door
that never moved: the new floor's leaf drove in from the previous floor's
opening angle. Measured on two spaces with a door in the same place and
opposite contact states: the node is reused, transform goes
`rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions
`running`. The marker shell adds two more with its `box-shadow`.

Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the
same lesson `glow-scene.ts` already learned for the Glow spots. The trap is
written where it starts — above the two transitions in `plan.styles.ts` —
because that is the file someone edits when adding the next animated property.
`houseplan-card.ts` is at its line ceiling, and the note would have cost the
budget a dozen lines for nothing: the swap itself is line-for-line.

The witness walks the shadow tree per element. `document.getAnimations()` is
empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the
document-level call does not reach into it, and the issue proposed exactly
that call. The smoke also builds its own fixture: the demo home has no
openings at all, so two doors in two spaces are prepared in the smoke, and it
asserts the other half of the contract as well — a real contact change inside
one space still animates the leaf.

On `origin/dev` the smoke fails on five facts, naming the offenders:
`op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow`
twice. Mutants `openings-rendered-without-keys` and
`device-markers-rendered-without-keys` put each `map` back.

Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base
512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8
(3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663
(910, 660) — `benchmark:compare` green in full.

The initial View graph grows 241 B gzip: `repeat` enters it for the first
time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated
note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The
301 066 B budget is untouched, but only 366 B now separate the ceiling from
it — the #367 headroom debt has stopped being theoretical.

Issue: #525
User-Visible: yes
2026-09-11 01:42:46 +00:00
claude[bot] 95175a7616 docs: review document for #525
Issue: #525
User-Visible: no
2026-09-11 01:20:43 +00:00
Claude 19e421b3cf Refresh the v1.74.0-beta.1 candidate on the merged line
The candidate was assembled before #520 and #521 landed. Version fields are
already at 1.74.0-beta.1 in all seven sources and the bundles rebuild
byte-identical, so this commit only moves what the beta says about itself:
the #521 changelog entry leaves Unreleased for the beta.1 section in both
languages, the release notes gain its bullet on both sides (four and four,
the grouped small-fixes bullet stays last), and STATUS records the two
issues merged since — the alignment guides that follow the gesture again
(#521, a regression shipped in v1.72.0 and v1.73.0) and the cold-start
adoption fix behind it (#520, internal).

No product change: `npm run build` plus `bundle-sync` leave `dist/**` and
the two mirrored trees untouched, `node scripts/release-contract.mjs
v1.74.0-beta.1` is green on all seven version sources, `npm test` 2515/0.

The `Release:` trailer is what asks CI for the heavy gates — smokes, golden
and the full performance comparison — on this exact SHA (#479).

Issue: #521
Issue: #520
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 23:59:13 +03:00
claude[bot] 3f3583ac58 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 20:48:53 +00:00
Claude 9a3af808d9 fix: hide the settled guides copy for the whole gesture, and prove it (#521)
Code review r1 was right that AC5's evidence was empty: the smoke never
forced a settled render during a gesture, so the settled copy of
`.hp-editor-only-layer` was empty at every point it looked, and
`groups() === 1` held whether the copy was hidden or not.

Measuring the case the reviewer named turned up more than a weak assertion.
Ownership of the layer alternates on its own — every settled render ends in
`updated()` → `_commitLiveEditor()`, which empties the live root — so a
settled render mid-gesture takes the guides back and draws them itself, from
the same live `_alignPoint`. That much needs no suppression. But the copy it
leaves behind stays in the settled scene, and the NEXT live paint adds a
second one: measured two `.alignline` on one alignment, the settled one a
grid step behind the marker. So the suppression stays, and now it stays with
a witness.

The smoke counts what is visible, not what is in the DOM: the hidden copy is
still a node, and counting nodes is how this check could have looked green
while showing the user two lines. Its device scenario now drives the whole
handover — force an unrelated settled render mid-drag (`_hdrH`, the same
header-height observer that masked the defect in the S2 measurements), assert
the render actually happened, that the layer went back to the settled scene
with the live point on it, and that one real move later the live painter owns
it again — exactly one visible guide at every step.

Mutant `live-editor-keeps-the-settled-guides-visible` puts the suppression
back under the plan branch, as it was before this issue, and the smoke goes
red on `nextMoveTakesTheLayerBack`.

Issue: #521
User-Visible: no
2026-09-10 23:25:46 +03:00
claude[bot] deebcfb095 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 20:05:44 +00:00
Claude 0715321fdc docs: refresh the screenshot source fingerprint (#521)
The fix touches `src/live-editor.ts` and `src/houseplan-card.ts`, and the
documentation fingerprint covers the whole `src/**` corpus, so `check-docs`
called the committed screenshot index stale — the same step #520 needed.

Accepted with `npm run docs:accept -- --identical` (#512): all 11 frames were
re-captured locally and matched the committed ones byte for byte, so only the
fingerprint moves, `c615d580 → 7a90ca04`. No PNG changes, nothing here needs
the owner's visual acceptance. Environment: Linux, Chromium 152 — the canon
platform of #455.

Issue: #521
User-Visible: no
2026-09-10 22:24:27 +03:00
Claude 53585b451b fix: alignment guides follow the live gesture again (#521)
#451 moved every editor gesture onto the live painter, and the guides stayed
behind in the settled scene. While a gesture runs, the settled scene is not
re-rendered at all, so the guides did not follow the marker in the device
editor, the shape in the backdrop editor, or the cursor while a contour is
drawn in the plan editor. Measured with real pointer events on the demo stand
against `origin/dev`, after waiting for the editor chrome to settle: three
gestures, each exactly on another object's axis, 0 settled render cycles,
`.alignline` 0 and no `.alignguides` group in all three.

The report called it two breaks. It is one — the layer — plus one thing that
would have broken the repair: `_alignPoint` read `_pos`, which during a live
gesture answers from the snapshot of the last settled render. Over one drag:
live 254.17 → 220.83 while `_pos` stayed at 254.17, eight grid steps behind,
so a restored layer would have drawn the guide at the marker's old place.

The live template now paints the guides in all three modes (the device editor
had no template at all — `paintDevice` only moves the marker element), and
`_alignPoint` takes the live position. The settled copy of
`.hp-editor-only-layer` is made transparent for the duration of any editor
gesture, not only in plan mode: two guides, one of them stale, is what the
user would otherwise see when an unrelated settled render lands mid-gesture.
`_renderAlignGuides` on the card becomes soft — a gesture that starts while
the editor runtime is still loading must cost nothing, and an exception inside
a `requestAnimationFrame` paint would take the whole gesture with it.

The witness is rewritten around the defect that hid this for two stable
releases: the old smoke assigned `_deviceDrag`/`_decorDraft` wholesale, and an
assignment with `oldValue == null` does not route to the live path — it
verified a state a real gesture never reaches. Every scenario now drives real
`PointerEvent`s, waits for silence first (the `_hdrH` settling window right
after entering a mode hands out settled frames that make even the broken code
draw a guide), and asserts zero settled cycles during the movements plus
exactly one `.alignguides` group. #400's exclusion is checked without touching
the drag state: the dragged marker must simply be absent from the candidates.
On `origin/dev` the smoke fails on nine of its facts; a witness that stays
green before the fix was the actual bug here.

Mutants: `live-editor-devices-drops-align-guides`,
`live-editor-decor-drops-align-guides`, `live-editor-plan-drops-align-guides`,
`align-point-reads-frozen-snapshot` — one per AC, all guarded by the smoke.
`test/smoke-harness-contract.test.mjs` pins that the smoke cannot go back to
fabricating gesture state.

Issue: #521
User-Visible: yes
2026-09-10 22:20:18 +03:00
claude[bot] fa7ac02c0f docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 19:05:32 +00:00
claude[bot] 548e7a8563 docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 18:57:55 +00:00
claude[bot] 9592092b8e docs: review document for #521
Issue: #521
User-Visible: no
2026-09-10 18:49:42 +00:00
claude[bot] c50bc725c8 docs: review document for #522
Issue: #522
User-Visible: no
2026-09-10 18:40:54 +00:00
claude[bot] b2346eaac3 docs: review document for #520
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Successful in 2m16s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 1m55s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 52s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #520
User-Visible: no
2026-09-10 18:26:40 +00:00
Claude a325294d7a docs: refresh the screenshot source fingerprint (#520)
The fix touches `src/houseplan-card.ts` and `src/config-adoption.ts`, and the
documentation fingerprint is computed over the whole `src/**` corpus, so
`check-docs` called the committed screenshot index stale — the Medium of code
review r2.

Accepted through `npm run docs:accept -- --identical` (#512): all 11 frames
were re-captured locally and compared byte-for-byte with the committed ones —
«Все 11 кадров попиксельно совпали с закоммиченными». Only the fingerprint
moves, `ccbbfb4e → c615d580`; not a single PNG changes, so nothing here needs
the owner's visual acceptance. Environment: Linux, Chromium 152 (the canon
platform of #455; capture refuses elsewhere).

`node scripts/check-docs.mjs` — «Documentation checks passed (7 files, 12
external links)».

Issue: #520
User-Visible: no
2026-09-10 21:15:42 +03:00
claude[bot] 3066fec17e docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 17:47:45 +00:00
Claude 462b56453c fix: the authoritative adoption keeps its own task (#520)
The r1 diagnosis was wrong, and the measurement in the code review proved
it: removing the two declarations from `static properties` left the cold
start at 19 update cycles, 4 model builds and 4 config epochs, exactly the
numbers of the bug. Lit's forced first-update change does mark `_serverCfg`
changed, but at that moment the body and `_cfgEpochPreservedConfig` are both
null, `preserveGeometry` is true and the epoch does not move. The comment
above `static properties` now says that; the declaration still stays out,
because two owners of one reactivity is what #500 removed.

The real cause is the `await`. Before #500 everything from
`_adoptStructuralResponses` to the end of the load ran in one task: the
adopted bodies, `_adoptInitialSpace`, the viewport restore, `_loadOk`, and
the device seeding — whose `_syncNewDevices`/`_seedHiddenDevices` write the
config back — all landed in a single Lit update. #500 made the adoption an
async sequence, so the caller resumes one microtask later, after Lit has
already painted the adopted config; the seeding writes then arrive as a
second config epoch, a second model build and a second paint of a 60-room
house.

`GatedAdoptionInput` gains `afterAdopt`, the mirror of `beforeAdopt`: it
runs synchronously at the end of the sequence, before the promise resolves.
`_loadFromServer` moves the viewport restore, `_loadOk` and the device
rebuild into it — `_syncNewDevices` refuses to write before `_loadOk`, so
the order inside the hook matters — and the load tail now rebuilds devices
only when nothing was adopted. `_reloadConfigOnly` takes the same route.

Measured with the project's own runner, 7 samples per profile, base
`a44fbd37` against this tree (Chromium 152, sandbox):

  interaction  modelReadyMs 761.3 ≤ 950.56 (base 731.2)
               firstStableRenderMs 2567.2 ≤ 3000 (base 2542.7)
               longTask.maxSingleMs 690 ≤ 921 · cache.entries.cleanFloor 100
  isometric    modelReadyMs 1252.9 ≤ 1499.76 (base 1249.8)
               firstStableRenderMs 1378 ≤ 1610.16 (base 1341.8)

Boot diagnostics on both trees: 18 update cycles, 3 model builds, 3 config
epochs, with the same epoch trace — the candidate is no longer
distinguishable from the base.

Witnesses. `config-adoption.test.mjs` queues a microtask at the start of
the adoption and pins that `afterAdopt` runs before it — the probe fails the
moment the hook crosses an await; `config-adoption-ownership.test.mjs` pins
the wiring in the card and the hook's place in the sequence. Mutants
`adoption-tail-defers-caller-hook` (defers the hook by one microtask) and
`authoritative-load-seeds-devices-after-the-await` (drops the rebuild from
the hook) redden them.

The initial View graph grows 40 B gzip, so the #438 ceiling is recentred
300 300 → 300 400 with the usual dated note; measured 299 812 B keeps 588 B
above and 1 412 B below the band. The overall 301 066 B budget and the #367
headroom debt are untouched.

Issue: #520
User-Visible: no
2026-09-10 20:12:13 +03:00
claude[bot] 49a02637ca docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 16:27:39 +00:00
Claude a625871538 test: a mutant for the only surviving wake-up of a replaced config body (#520)
With the bodies no longer declared as Lit properties, `onBodyReplaced` is the
single path that turns a replaced config reference into an update — AC2 of
this issue rests on it, and until now only a hand-run mutation stood behind
that column. The mutant drops the notification from `setConfig`; the existing
unit in `test/config-adoption.test.mjs` reddens on it.

Verified: `node scripts/mutation-gate.mjs --id=adoption-notifies-no-host-on-config-replacement`
— «поймано 1 из 1».

Issue: #520
User-Visible: no
2026-09-10 19:03:21 +03:00
Claude e181b08f2b fix: the adoption bodies are no longer declared Lit properties (#520)
#500 gave `_serverCfg` and `_layout` prototype accessors but left them in
`static properties`. Lit marks such a property `wrapped` and, on the FIRST
update, force-writes it into `changedProperties` with an `undefined` old
value even though nobody assigned anything (`reactive-element.js:249-252`
and `:880-886`). `willUpdate` reads that as a config replacement, raises
`_cfgEpoch`, the memoized model key changes, and a 60-room house builds and
paints its model a second time: measured 19 update cycles, 4 builds and 4
epochs against 18 / 3 / 3 before #500, worth ~550 ms of `modelReadyMs` and
the same on `firstStableRenderMs` (3355 against a 3000 ceiling).

The declaration goes; the bodies stay reactive through the owner —
`_adoption` → `onBodyReplaced` → `requestUpdate(field, previous)` — which
needs no declaration: `getPropertyOptions` falls back to the default and
`changed.has('_serverCfg')` works as before. `noAccessor: true` would not
help, `wrapped` is set before that flag is read. The trap is written above
`static properties`, where someone would put the declaration back.

`cache.entries.cleanFloor` returns to 100 in both interaction budgets: the
120 entries were the extra epoch re-keying the per-room cache, not a
property of the design — the reasoning in 914e8402 was wrong.

Witness: test/config-adoption-ownership.test.mjs pins that neither body is
declared; the mutant `adoption-bodies-declared-reactive` puts the
declaration back and reddens it.

The boot diagnostics of the previous three commits touch four private
members, so they are declared in the performance contract: `_buildModel` and
`_cfgEpoch` outright (both exist in every supported comparison base), and the
adoption entry point as a current/legacy pair — #500 turned the private
`_adoptStructuralResponses` into the public `_adoptAuthoritative`, and an
undeclared rename would have the counter report zero adoptions instead of
failing.

The same commits carried a `node_modules` symlink: `.gitignore` had the
pattern with a trailing slash, which does not cover a symbolic link, and
`git add -A` in a sandbox worktree committed it. The link is removed and the
pattern loses the slash; a mutant run on this branch failed with `EEXIST` on
it.

Issue: #520
User-Visible: no
2026-09-10 18:58:24 +03:00
claude[bot] 50fa7f9be2 docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:38:40 +00:00
claude[bot] b49721a0bd docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:30:21 +00:00
claude[bot] ad2f9afd2b docs: review document for #520
Issue: #520
User-Visible: no
2026-09-10 15:20:07 +00:00
Claude 27ea23c1ec test(perf): trace every config-epoch bump with the frame that made it (#520)
The comparison already names the mechanism: base does 18 update cycles, 3
model builds and ends at epoch 3, the candidate does 19, 4 and epoch 4, and
the extra build is the whole ~550 ms. What is still missing is the caller.
The diagnostic now installs an instance-level setter over `_cfgEpoch` and
records `from->to` with the top stack frames, so the extra bump names itself.

Issue: #520
User-Visible: no
2026-09-10 17:57:27 +03:00
Claude 3d43980568 test(perf): the boot diagnostics travel out of the page into the run log (#520)
The first attempt printed them with console.log inside page.evaluate, and
nothing forwards the page console to Node — the numbers went nowhere. The
sample now returns `bootDiag`, the runner prints it and strips it before the
row is recorded, so the budgeted record keeps its shape.

Issue: #520
User-Visible: no
2026-09-10 17:47:18 +03:00
Claude 37ef970dfc test(perf): count update cycles, model builds and adoptions during boot (#520)
The full comparison says model readiness grew by ~500 ms inside #500 and
that the growth sits in one long task, but neither contentFingerprint
(2.8 ms on this fixture) nor spaceModels (0.1 ms) can account for it. The
benchmark now prints, per sample, how many Lit update cycles ran before the
first stable frame, how long they took together, how many models were built,
how many adoptions happened and the config epoch. Diagnostics only: printed
to the log, never part of the budgeted record, and the same harness runs
against the comparison bundle, so candidate and base are counted alike.

Issue: #520
User-Visible: no
2026-09-10 17:38:36 +03:00
Claude 914e84028d perf: the clean-floor cache holds one entry per room per config epoch (#509)
Проверка (CI) / Классификация изменённых файлов (push) Successful in 52s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m32s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 43s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 41s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Failing after 4m28s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Failing after 4m36s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Failing after 4m43s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Failing after 4m38s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Failing after 3m58s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Failing after 4m7s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m49s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m2s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The pre-release perf gate of the v1.74.0-beta.1 candidate reported
cache.entries.cleanFloor 120 against a ceiling of 100 (run 34480302982,
large-house-interaction-v1). The ceiling was calibrated when only the visible
space populated `_cleanFloorCache`; since #509 the summary panel computes the
clean-floor total in per-room slices through the same cache, so the sixty
fixture rooms are cached under both config epochs the interaction profile
creates — 60 × 2 = 120, exactly what the run measured.

The ceiling moves to 180 in the smoke and in the full interaction profile:
one entry per room per epoch with room for a third epoch, far below the LRU
cap of 600 and far below anything a per-frame or per-marker regression would
produce. The leak detector is untouched: cacheGrowth.cleanFloor stays 0.

Issue: #509
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 16:29:31 +03:00
Claude d5575f6393 Prepare v1.74.0-beta.1
Version fields and the generated bundles move to 1.74.0-beta.1 and open the
line after the stable v1.73.0. The changelog entries of #509 and #508 leave
Unreleased for the beta.1 section; release notes and STATUS describe them and
name the internal work of this beta. No product change beyond what is already
reviewed and merged.

Issue: #509
Issue: #508
User-Visible: no
Release: v1.74.0-beta.1
2026-09-10 16:01:59 +03:00
Claude 1950df608f docs: refresh the screenshots fingerprint before the v1.74.0-beta.1 candidate
The #500 adoption work changed src/** without touching any of the eleven
documented frames. The owner re-captured them in WSL — the canonical Linux
environment, because DirectWrite on native Windows never reproduces an
accepted frame — and `npm run docs:accept -- --identical` (#512) found zero
differing pixels, so only the source fingerprint moves:
48f770cf → ccbbfb4e. Frame bytes, their hashes and the capture-script guard
stay exactly as accepted.

Issue: #500
User-Visible: no
2026-09-10 16:01:58 +03:00
claude[bot] 94ea4694f4 docs: review document for #500
Проверка (CI) / Классификация изменённых файлов (push) Successful in 47s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m24s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m14s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #500
User-Visible: no
2026-09-10 12:32:21 +00:00
Codex be6d57e96f test(adoption): the post-write smoke resets its own pending writes
Review r2 M1. The smoke's reset() left the previous scenario's debounced
config/layout write pending; _deleteSpace flushes whatever is pending
before it writes, the fake socket answers without a rev, and the
documented rev+1 fallback then moved the revision on a body from another
scenario. The refused branch looked as if it had adopted:
onboardingDeleteRefusedAdoptsNothing was red on every run. The scenarios
are supposed to be independent, so reset() now cancels both debounced
writers, as smoke_danger_confirmation already does.

37/37 green, three runs in a row; with the cancel removed the same
single check goes red again.

The refused-tail fix from r1 had no witness in CI at all: no mutant
named this smoke as its guard, so the review gate never ran it and a red
witness survived a whole round. A witness that never runs is not a
witness, so the early return in _undoPlanOptimization now has a mutant
that names the smoke.

Issue: #500
User-Visible: no
2026-09-10 15:16:12 +03:00
claude[bot] 5acf04fa33 docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 12:08:21 +00:00
claude[bot] 93c6c7551b fix(adoption): post-write callers skip their tail on a refused gate (#500 r1 M1)
`space/delete` (both runtimes), Optimize Undo and Import apply now treat
`asset-wait` like every reload path: nothing was adopted, so no toast, no
space switch, no history/undo reset — the dialog is released and the
scheduled reload owns the rest. Unit and smoke cover the refused branch for
all four paths; the spec's reactivity risk row states the real mechanism.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] bac63dbcaf docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 31c2583e46 fix(adoption): a replaced body stays a reactive host event (#500)
`willUpdate` keys the geometry epoch and render-lifecycle invalidation on
`changed.has('_serverCfg')`. Before #500 every body replacement went through
Lit's accessor and produced that event; the owner wrote its field directly
and the epoch stopped moving on adoption, staging and rollback — the safe
Resize smoke then measured against a stale model (Validate on c360bcc9).
`MutableConfigAdoption` now reports each replaced reference through
`onBodyReplaced`, which the card wires to `requestUpdate(field, previous)`;
echoes and identity-only changes stay silent, exactly as an unchanged
reference never fired the accessor.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 2f41c0b29e test(adoption): ownership lint, post-write smoke, harness seam and docs (#500)
`test/config-adoption-ownership.test.mjs` pins identity writes to the owner
and ratchets body staging (AC1/AC2). `demo/smoke_post_write_adoption.mjs`
drives space/delete (both runtimes), Optimize Undo and Import apply with a
concurrent backdrop change between the write and the re-read (AC4). Smokes
that seed revisions from outside the card keep working through the
`seedIdentity` harness seam behind the card's delegate setters. The initial
View ceiling is re-centred with the measured fact; ARCHITECTURE.md gets the
boundary paragraph.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] a3e7ac1fc6 test(adoption): revision guard witness for optimistic rollback (#500 AC6)
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 7194fb2df0 feat(adoption): one owner for config/layout identity and the adoption sequence
`src/config-adoption.ts` owns config/layout with revision and fingerprint;
the host keeps `_serverCfg`/`_cfgRev`/`_layout`/`_layoutRev` as delegates.
All seven authoritative adoptions go through `adoptAuthoritativeGated`
(backdrop readiness → continuity → adopt → profile tail); the post-write
paths (space/delete ×2, optimize_undo, import/apply) gain the gate and take
revisions from the re-read bodies. `rollbackOptimistic` moves to the owner;
plan-optimize, space copy and the vacuum writers stop assigning identity.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] a44fbd373d docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] c21e9de32b docs(spec): #500 r3 — post-write profile keeps caller tails untouched
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 9bbba66d11 docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] b4a2d5b952 docs(spec): #500 r2 — seven adoption callers, two profiles, vacuum writers
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] b6a66a1bac docs: review document for #500
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
claude[bot] 88eb382403 docs(spec): #500 one ownership boundary for config adoption
Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00
Codex f6eac1d971 docs: the review gate charges a round only for what the round changed
Issue: #518
User-Visible: no
2026-09-10 14:31:06 +03:00
Codex 67c780a742 test: the review gate's own job names follow the six shards
Issue: #518
User-Visible: no
2026-09-10 14:20:34 +03:00
Codex 2783ceff94 perf(ci): judge a witness by its anchor's neighbourhood, not the whole file
The review gate re-ran almost every selected witness on every round even
when the executor's fix was twelve lines: the ledger fingerprint and the
diff selection both worked on whole files, and the card hosts are
thirteen thousand lines each. On #500 those twelve lines in
houseplan-editor-runtime.ts pulled 53 of the 75 witnesses the third
round ran, and the gate cost 140 job-minutes and an hour of the
reviewer's wall clock across three rounds.

The patch side is now judged by the anchor's neighbourhood — the anchor
lines plus ANCHOR_RADIUS_LINES on each side — in both the ledger
fingerprint and the diff selection, which now reads hunk ranges from
git diff --unified=0. The guard side keeps whole-file granularity: a
guard has no anchor and changes as a whole. An anchor that is not found
exactly once falls back to the whole file, and so does a file whose
hunks were not read: not knowing is not proof. Same class of
approximation as the existing diff selection, with the nightly full
gate (#513) as the floor.

Two more cuts to the wall clock of a review round. The shard plan is now
computed before the environment is installed — restore the ledger,
select, split, and only then pay for npm ci, Python and Chromium; the
job still runs, so the review gate's proof (#510) is unaffected. And the
matrix goes from three shards to six: the same job-minutes, half the
wall time.

On the #500 round the selection drops 60 → 7. Four witnesses guard the
new logic, including the two unsafe defaults (ambiguous anchor, missing
hunks).

Issue: #518
User-Visible: no
2026-09-10 14:17:36 +03:00
claude[bot] 0c74828240 docs: review document for #509
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 51s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 37s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 55s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m29s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 1m3s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 8m21s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 10m42s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #509
User-Visible: no
2026-09-10 09:12:51 +00:00
Codex 9b00002a5b test: allow the indivisible per-space masonry slice in the first-paint budget
The residual 150–200 ms tasks are one space's wallBodiesGeometry — a
single polyclip union that cannot be split — not the aggregate the issue
is about. The threshold now names them and leaves room for a slower CI
runner; the mutant that computes everything in one task still produces
~1.5 s and fails.

Issue: #509
User-Visible: no
2026-09-10 11:48:30 +03:00
Codex 19b14d3976 test: the first-paint smoke watches only the panel's own long tasks
On the CI runner the smoke went red without any mutant: the observer was
started before the 60-room plan had finished drawing, and that render —
a long task of its own, unrelated to this issue — landed in the window.
The smoke now waits for a quiet main thread before it starts watching,
prints what it measured, and allows up to 450 ms per task: the residual
150–200 ms slices are one space's masonry union, which polyclip cannot
split, while the mutant that puts the whole aggregate back into one task
still produces ~1.5 s.

Issue: #509
User-Visible: no
2026-09-10 11:47:25 +03:00
Codex 172d9d5a00 docs: refresh the screenshots fingerprint after the summary-panel fix
All 11 frames are pixel-identical (docs:accept --identical, #512); only
the source fingerprint moved with src/**.

Issue: #509
User-Visible: no
2026-09-10 11:35:26 +03:00
Codex 588ef42546 fix: the clean-floor total is computed in per-room slices, not one long task
Moving the aggregate out of render fixed the first frame, but the work
itself was still one uninterrupted ~1.5 s task on the large-house
fixture — the interface stayed frozen, just a moment later, which is the
same symptom the issue reports. cleanFloorAreaSteps yields after every
room; the runtime advances it with an 8 ms budget per frame and
reschedules until it finishes, so no slice outlives a frame and the
skeletons stay until the number is ready.

The smoke now watches longtask entries for the whole show, not only the
first frame: a single long task while the values are computed fails it.

Issue: #509
User-Visible: no
2026-09-10 11:31:37 +03:00
Codex e2bb1e5390 test: the clean-floor total is timed on a real floor, not just counted
The injected-counter test proves "one geometry pass per space" but not
that its result reaches innerContourForRoom — without the shared
arguments that function rebuilds the masonry per room, and the only
observable difference is time (176 ms per room, S2). One large-house
floor: ~0.6 s with the shared pass, ~3.7 s without, so a 2.5 s threshold
is coarse enough not to flake.

Issue: #509
User-Visible: no
2026-09-10 11:26:29 +03:00
Codex 398281a93f fix: the summary panel's first paint no longer freezes or claims the sources are gone
Two halves of the same first paint. The panel showed «Source unavailable»
in every row until the lazy metrics chunk arrived, because value() could
not tell "not loaded yet" from "source is dead"; and metrics() ran inside
render, walking the HA registry and unioning the clean floor of every
space synchronously — 11 s on the large-house fixture.

- totalCleanFloorAreaM2 computes the space's masonry and junction
  topology once per SPACE and hands them to innerContourForRoom, which
  otherwise unions the whole space again for every room: 11 045 → 1 488 ms
  on that fixture, same 306.3 m². The card has always done this through
  its own _innerContour cache; the panel now does the same.
- Aggregates leave the render path: the first frame paints skeletons and
  the work starts right after the frame is shown (timeout → rAF →
  timeout, never requestIdleCallback, which under load would leave the
  skeleton up for seconds). Stale memo keeps the previous number on
  screen instead of flashing back to a skeleton.
- valueState() separates pending from unavailable; a pending row keeps
  the same plate, grid and height and carries a pulsing rectangle the
  height of the line, replaced by the value with a short fade. Reduced
  motion keeps the rectangle and drops the pulse.
- Panel enter/exit animation (#505, 190 ms) is now actually visible —
  the main thread is free — and the smoke witnesses it.

Mutants: summary-first-paint-shows-unavailable, summary-metrics-block-first-frame,
summary-area-recomputes-walls-per-room, summary-stale-metric-falls-back-to-skeleton.

Issue: #509
User-Visible: yes
2026-09-10 11:24:10 +03:00
claude[bot] 9d375dcb40 docs: review document for #509
Issue: #509
User-Visible: no
2026-09-10 08:14:22 +00:00
claude[bot] 57f38fedba docs: review document for #509
Issue: #509
User-Visible: no
2026-09-10 08:05:30 +00:00
Codex ae8f6728d7 ci: mirror process.yml from dev (#517 — issue-body digest in review anchors)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m50s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m12s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m14s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m49s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m48s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m54s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 1m58s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 57s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 39s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m53s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 43s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Failing after 4m9s
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Failing after 4m15s
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Failing after 4m18s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 8m1s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The review pipeline runs from the default branch; byte-identical to
dev@d204bf5d.

Issue: #517
User-Visible: no
2026-09-10 10:38:38 +03:00
claude[bot] d204bf5d77 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 07:36:17 +00:00
Codex 156835c048 ci: specs live in the issue body — body digest in review anchors, gate without a spec file
The spec file solved exactly one problem — proving that a review verdict
was passed on a given text — and created two: docs/specs/README.md
conflicted between parallel tasks and served as a second, stale status
dictionary, and every spec edit cost a commit, a push and a label. The
proof moves into the pipeline.

- review-doc-guard: normalizeIssueBody / issueBodyDigest (CRLF, trailing
  whitespace, trailing newlines), the anchor line `Тело issue: <sha256>`,
  anchorIssueBodyFrom, and issueBodyChanged — the finding "the spec
  changed after a green spec review", judged against the pipeline's own
  record in the last green SPEC-REVIEW, never against prose.
- reusableGreenVerdict takes the current digest: reuse (#499) skips the
  model entirely, so without this a spec edit between rounds would pass
  unseen. Documents without the record (the whole backlog) keep judging
  by tree.
- process.yml: the material step reads the body with `gh issue view` in
  the same run that fixes the material — the event snapshot describes a
  text the reviewer may never see; the digest goes into the anchors, into
  reuse and, when it differs, into the reviewer's prompt.
- process-gate: rule 3 judges the text (a `## ТЗ` heading or an AC1) with
  the archived file still accepted; adding a new file under docs/specs/
  warns — the directory is frozen.
- task-packet reads AC from the body first, the archived file second.
- PROCESS.md §2.3/§5/§7.1/§7.3/§10.5, AGENTS.md and docs/specs/README.md
  say so; the index table is gone with the long-standing §7.3 debt.

Mutants: review-anchor-drops-issue-body, review-ignores-changed-spec-body,
reuse-ignores-changed-issue-body, process-gate-requires-spec-file.

Issue: #517
User-Visible: no
2026-09-10 10:18:17 +03:00
claude[bot] e20560afa8 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 07:08:05 +00:00
claude[bot] 6b31e94501 docs: review document for #517
Issue: #517
User-Visible: no
2026-09-10 06:54:51 +00:00
claude[bot] a824acc1b1 docs: review document for #502
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 58s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 40s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m11s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Issue: #502
User-Visible: no
2026-09-10 06:34:45 +00:00
Codex a72f5274ca ci: main keeps its own validate.yml — only process.yml and mutation-gate.yml are mirrored
Mirroring validate.yml into main (e9638977, #510) left the branch with a
workflow its own tests do not describe: main still carries the v1.73.0
test suite, which pins the pre-#510 `changed_mutants` condition, so every
push to main has been red since 09.09 15:56. Only process.yml and
mutation-gate.yml must match across branches — they run from the default
branch, which is why the preflight compares exactly those two;
validate.yml runs from the branch under test and travels with dev on the
next stable promotion.

Issue: #510
User-Visible: no
2026-09-10 09:34:41 +03:00
Sergey Matyuninandclaude[bot] 441d3cc3de test(i18n-dead-keys): narrow dynamic consumers to key-shaped joins (#502)
Issue: #502
User-Visible: no
2026-09-10 06:27:59 +00:00
Codex 9ab4896c90 ci: mirror release.yml from dev (#514 — E2E gate for stable releases)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m55s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 1m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m5s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m4s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 1m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 1m58s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m0s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 2m3s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m10s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 42s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 1m59s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 24s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 21s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 48s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 9m58s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m26s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
The `release` event runs the workflow from the default branch;
byte-identical to dev@44046025.

Issue: #514
User-Visible: no
2026-09-10 00:44:17 +03:00
claude[bot] 9293109ba1 docs: review document for #516
Проверка (CI) / Классификация изменённых файлов (push) Successful in 35s
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 53s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / HACS: валидация репозитория (push) Failing after 22s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 22s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m1s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m54s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 4m28s
Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
claude[bot] 96bfd94313 docs: review document for #516
Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
Codexandclaude[bot] 3ad5d0baea ci: merge-candidate compares patch-ids without the review documents
The candidate is the branch tip, which already carries the round's
CODE-REVIEW-N-rK.md; the material the reviewer read does not. With
docs/reviews in the diff the two patch-ids never matched once dev had
moved, so every green candidate went back to review whenever another
task published its own document in the meantime — #514 looped twice on
09.09 and #508 only merged when dev happened to stand still. The
patch-id now excludes docs/reviews, exactly like `reviewedFresh` next to
it; a real change of the patch under rebase still returns the task.

Mutant: merge-rereviews-own-review-doc.

Issue: #516
User-Visible: no
2026-09-09 21:39:17 +00:00
claude[bot] 440460255b docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:37:26 +00:00
Codex aeb0a473db ci: mirror process.yml from dev (#515 — material anchors after the rebase)
The review pipeline runs from the default branch; byte-identical to
dev@ad2858a8.

Issue: #515
User-Visible: no
2026-09-10 00:21:26 +03:00
Codexandclaude[bot] 0ce5e4eed3 ci: the E2E gate fails loudly when the release list cannot be read
Code review r3 (M1): realOps.releases() swallowed a failing `gh release
list` into an empty list, so a fine-grained token scoped to houseplan-e2e
alone would have dispatched with upgrade_from=stable — the tag onto
itself — and the red run would look like the bug 4143f998 already fixed.
The call now throws like dispatch() and lands in the same catch: result
`error` with the token hint, which now names both repositories. L4:
PROCESS.md says who dispatches and who waits.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] ed6d1d76db docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] a8a9b7d99e docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] 39571e20d6 docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 0796a01571 ci: the E2E gate recognises its run by the suites that install the tag
Two findings from the live run on v1.73.0 (houseplan-e2e run
34393136097): the upgrade job carries the previous stable's tag in its
name, so "any job with HP <tag>" let a gate for v1.72.0 adopt the
v1.73.0 run — recognition now keys on `journeys`/`first-run`; and the
first poll after a dispatch sees only the matrix-planning job, which
marked the run as foreign forever — a run without any `· HP … ·` job is
undecided and polled again. Live: v1.73.0 → green with the run link,
v1.72.0 → no run of its own.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 57b19f9914 ci: the E2E gate upgrades from the previous stable, not from the tag under test
Live run on v1.73.0 (houseplan-e2e run 34392391382): at `release:
published` the new tag is already the newest stable, so
`upgrade_from=stable` made the upgrade suite update v1.73.0 onto itself
and fail with `Expected: not "1.73.0"`. The gate now resolves the newest
non-prerelease, non-draft release other than the tag from `gh release
list` and passes it as `upgrade_from`; the first stable ever falls back
to `stable`. Spec §4/§6 record the change and the matrix-planning job in
houseplan-e2e (a job-level `if` cannot read `matrix.*`).

Mutant: release-upgrades-stable-onto-itself.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] c50458a098 ci: a stable release waits for a green E2E run on a real Home Assistant
The stable gate proved Validate and Full Performance on the exact SHA but
never ran the release in Home Assistant itself. houseplan-e2e installs
the release's houseplan.zip — the bytes HACS ships — into HA in docker
and walks the sidebar page, dashboards, roles, PDF, restart and the
stable→tag upgrade. release.yml now dispatches e2e.yml on the tag for
`!prerelease` releases and waits for it (scripts/e2e-gate.mjs, modelled
on validate-gate.mjs): the gate recognises its own run by `HP <tag>` in
the job names, ignores foreign dispatches, and reports red / missing /
cancelled / token error with the run link. Betas are untouched.

Mutants: release-ships-on-red-e2e, release-trusts-foreign-e2e-run.

Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] d20ef60037 docs: review document for #514
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
Codexandclaude[bot] 8f037a74dc docs: spec for #514 — E2E on a real Home Assistant as the stable release gate
Issue: #514
User-Visible: no
2026-09-09 21:18:51 +00:00
claude[bot] ad2858a80c docs: review document for #515
Issue: #515
User-Visible: no
2026-09-09 21:13:52 +00:00
Codex 5a1cddeaf0 ci: review anchors are taken after the pipeline's rebase, not before
The material anchors (commit, tree, spec blobs) written into every
review document came from the checkout step, before "Привести ветку к
dev". Whenever dev had moved — since 09.09 every review-document publish
moves it — the pipeline rebased and force-pushed the branch, orphaning
the pre-rebase commit and its tree. A fresh clone in the next run could
not resolve that tree: reuse (#499) always reported false and the
model reviewed the same code again, and the #413 post-step refused the
green round because neither the cited SHA nor the tree anchor was
reachable — #508 took three identical green rounds this way.

The `material` step, which already fixes the reviewed SHA after the
rebase, now also records the tree and spec blobs, and the publish step
reads all three from it. The contract test pins the order and forbids
reading anchors from the checkout step.

Issue: #515
User-Visible: no
2026-09-10 00:04:41 +03:00
claude[bot] 3c1b4ebee2 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 20:55:09 +00:00
claude[bot] be93291065 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 20:02:08 +00:00
claude[bot] 6ac513caa0 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:49:18 +00:00
Codexandclaude[bot] e38beed796 fix: the summary panel settings dialog scrolls in Home Assistant
In HA hp-dialog renders ha-dialog, whose own `.body` is the scroller and
is not a flex container; `.summary-editor` (overflow:auto,
overscroll-behavior:contain, min-height:0) therefore grew to its content
and became a scroll container that never scrolls — Chromium stops wheel
and touch scroll chaining at such a child, so nothing moved (reproduced
on ha.jbstudio.pro, HA 2026.9.1, and in an isolated Playwright page).
hp-dialog gains an opt-in `flex-content` attribute forwarded as
ha-dialog's `flexcontent`, which lays the body out as a flex column: the
editor is height-bound again and scrolls itself, header and footer stay,
exactly as the native branch already did. Only the summary dialog opts in.

Smoke demo/smoke_summary_dialog_scroll.mjs stubs ha-dialog after the HA
2026.9 contract: wheel on desktop, touch swipe on a phone, dialog within
the viewport, and a witness that the stub reproduces the bug without
flexcontent. Docs screenshots: 11/11 pixel-identical (docs:accept
--identical, #512), fingerprint refreshed.

Mutants: summary-dialog-drops-flex-content, hp-dialog-ignores-flex-content.

Issue: #508
User-Visible: yes
2026-09-09 19:49:18 +00:00
claude[bot] 6b592c13c0 docs: review document for #502
Issue: #502
User-Visible: no
2026-09-09 19:35:30 +00:00
claude[bot] 72764cff90 docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:21:54 +00:00
claude[bot] f02af9330b docs: review document for #508
Issue: #508
User-Visible: no
2026-09-09 19:01:45 +00:00
Codex c544c26b66 ci: mirror mutation-gate.yml from dev (#513 — nightly schedule)
The schedule runs from the default branch; byte-identical to dev@fa01aa00.

Issue: #513
User-Visible: no
2026-09-09 21:12:20 +03:00
claude[bot] fa01aa00bf docs: review document for #513
Issue: #513
User-Visible: no
2026-09-09 18:09:24 +00:00
Codex 0b9acd6382 docs: the local pre-push gate no longer calls the full mutation registry a pre-release gate
Code review r1 (M1): scripts/pre-push-gate.mjs — in its comment and in
the text every developer sees before a push — still named the full
mutation registry a pre-release gate; the same phrase lived in the
header of test/mutation-gate.test.mjs. Both now point at the nightly
schedule (#513); golden/smokes/HA harness are named as the heavy
Validate set on the candidate.

Issue: #513
User-Visible: no
2026-09-09 21:00:54 +03:00
claude[bot] f5fb7a0f8a docs: review document for #513
Issue: #513
User-Visible: no
2026-09-09 17:58:17 +00:00
Codex 61905bacdc ci: full mutation gate runs nightly, outside the development and release cycle
The full registry run proves that tests can fail, not that the product
works; 4 of its 5 runs since 02.09 were manual dispatches tied to
releases. Owner decision 09.09: a daily schedule (01:00 UTC, before the
02:30 nightly Validate), failures reported as an issue by the existing
#472 job, no place in the development or release flow. Docs and the
workflow comments say so; the test pins the daily cron.

Issue: #513
User-Visible: no
2026-09-09 20:49:08 +03:00
claude[bot] 9b65d4bcf7 docs: review document for #512
Проверка (CI) / Классификация изменённых файлов (push) Successful in 36s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 1m0s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 41s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 19s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 1m10s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 12m6s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 5m0s
Issue: #512
User-Visible: no
2026-09-09 16:15:24 +00:00
Codex e96389778f ci: mirror process.yml and validate.yml from dev (#510 — mutants on request, review gate)
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (interaction) (push) Failing after 1m59s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (blend) (push) Failing after 2m4s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric) (push) Failing after 2m38s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (isometric-stage3) (push) Failing after 2m53s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (large-house) (push) Failing after 2m3s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (overlay) (push) Failing after 2m6s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (plan-snap) (push) Failing after 1m56s
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-default) (push) Failing after 2m8s
Проверка (CI) / Классификация изменённых файлов (push) Successful in 41s
Проверка (CI) / Мутанты по диффу (1/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (2/3): затронутые свидетели краснеют (push) Skipped
Проверка (CI) / Мутанты по диффу (3/3): затронутые свидетели краснеют (push) Skipped
Полные бенчмарки производительности / Бенчмарки рендера и геометрии (space-glow) (push) Failing after 2m3s
Проверка (CI) / HACS: валидация репозитория (push) Failing after 40s
Проверка (CI) / Hassfest: манифест интеграции (push) Failing after 38s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 56s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Failing after 9m39s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 11m56s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 16m28s
The review pipeline runs from the default branch: the Validate-with-
mutants gate before Review and the `mutants` dispatch input must exist
here as well. Files are byte-identical to dev@33cb131b.

Issue: #510
User-Visible: no
2026-09-09 18:56:03 +03:00
Codex ffe2ec51c0 test: golden baselines show 0.0.0-golden instead of the card version
Re-acceptance after the version seam (#512 §7) from the full Validate
dispatch on 605991ef (run 34366858855): seven frames carried the version
text — three version-mismatch banners, three PDF footers, the support
preview — and now read `0.0.0-golden`; six of them were within threshold
and still showed stale betas (beta.4, beta.8). The other 162 frames are
kept as reviewed; 128 environment witnesses matched byte for byte.
Sub-threshold drift in nine unrelated frames (tray, resize handles,
compass, junction) is not accepted.

Issue: #512
User-Visible: no
Release: v1.74.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/34366858855
2026-09-09 18:53:46 +03:00
Codex b7d8b9e6ee test: displayed version through a seam; docs:accept --identical for pixel-identical screenshots
Golden frames carried the card version text (about, version banner,
support/export previews), so every beta bump re-accepted up to 20
baselines that had not visually changed. The version now reaches the DOM
and stand requests through displayVersion() (src/card-version.ts); the
golden harness pins window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'
before the card is created. CARD_VERSION literals stay where the release
contract reads them; cache-busting and the console banner keep the literal.

Docs screenshots: `npm run docs:accept -- --identical` re-captures
locally, compares decoded RGBA pixels with the committed frames inside
Chromium (scripts/png-identical.mjs) and, only when every frame is
identical, refreshes the manifest fingerprints and captureScriptSha256;
bytes stay, any difference refuses with a per-frame count. First run on
this tree: 11/11 identical, manifest refreshed.

Mutants: version-seam-ignores-override, docs-identical-accepts-any-frame.

Issue: #512
User-Visible: no
2026-09-09 18:53:38 +03:00
claude[bot]andCodex e5a62f7ffd docs: review document for #512
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
Codex 7674b78c33 docs: spec #512 r2 — capture.mjs untouched, --identical refreshes the capture-script guard
Spec review r1 (H1): the `--out` flag would have changed the only file
guarded by `captureScriptSha256` and reddened check-docs on its own. The
identical-accept mode now leaves demo/docs/capture.mjs as is (frames are
backed up and restored around the standard capture) and takes
`captureScriptSha256` from the candidate manifest together with the
source fingerprint; the docs re-acceptance for this issue is the first
local --identical run in the same branch.

Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
claude[bot]andCodex 3f402a3481 docs: review document for #512
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
Codex 278d9257eb docs: spec for #512 — version seam outside golden frames, docs:accept --identical
Issue: #512
User-Visible: no
2026-09-09 18:52:44 +03:00
claude[bot] 33cb131b3a docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:52:11 +00:00
Codex 615181050b test: the real waitValidate is exercised against a cancelled dispatch
Code review r2 (M1): the cancelled-run filter in merge-candidate's
waitValidate had no test or mutant — every test replaced ops.waitValidate
with a fake. realOps now takes an injectable `exec` (default: the same
spawnSync wrapper) so the real implementation runs against scripted
`gh run list` answers: a cancelled dispatch is skipped and its
replacement followed; a lone cancelled run ends in `missing`, never red.

Mutant: merge-trusts-cancelled-dispatch.

Issue: #510
User-Visible: no
2026-09-09 18:45:44 +03:00
claude[bot] 8acbd333d8 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:43:09 +00:00
Codex 00130e08aa ci: a cancelled Validate dispatch proves nothing to the review gate
Code review r1 (M1): validate-gate.mjs and merge-candidate's waitValidate
read a `cancelled` dispatch run on the material as red, so a dispatch
replaced by the next one in the `validate-dispatch-<ref>` concurrency
group would have returned the task S7→S6 for nothing — the same class
#511 fixed in release-gate.mjs. Cancelled runs are now ignored: the gate
follows the replacement dispatch, or starts its own when there is none.

Mutant: review-returns-task-on-cancelled-dispatch.

Issue: #510
User-Visible: no
2026-09-09 18:33:24 +03:00
claude[bot] f6e64ba985 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:29:18 +00:00
Codexandclaude[bot] cbece6324f test: re-anchor the review-starts-on-red-validate mutant after the r1 verdict split
The mutant registry pointed at the pre-r1 verdict helper that no longer
exists; the anchor test caught it in CI. The patch now removes the red
branch of the completed-run check.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 97dfa457a4 ci: diff mutants only on request; the review pipeline proves them on the material before reviewing
Validate ran the three "Мутанты по диффу" shards on every push of every
branch: 48 of 56 job-hours on 08–09.09, most of them cancelled by the
next push. Mutants now run when asked — pull requests, the nightly
schedule, a push carrying a `Release:` trailer, or a dispatch with
`mutants=true` (classify-changes.mjs → `mutants_requested`); an ordinary
push runs the light checks only.

The proof moves to where it is consumed. process.yml gets a gate after
the #499 reuse step: on the code stage it looks for a dispatch Validate
run on the exact material SHA whose mutant jobs executed and passed
(scripts/validate-gate.mjs); none → it dispatches one and waits; red or
missing → the task goes back S7→S6 with the run link and the review
cycle is not spent. Spec stage and the reuse fast-path skip the gate
(`proceed=true`); all later steps branch on `proceed` in place of the
old conflict conjunct only. merge-candidate.mjs dispatches Validate on
the pushed candidate and waits for that dispatch run.

PROCESS.md/AGENTS.md: review does not start on red code; one handoff —
one push.

Mutants: mutants-run-on-every-push, review-starts-on-red-validate,
review-trusts-push-run-without-mutants, merge-waits-push-run-without-mutants.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 34c5c2d134 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 73d6e2eeeb docs: spec #510 r4 — proceed replaces only the conflict conjunct
Spec review r3: `proceed` is true on the reuse fast-path too, so it must
replace the `rebase.conflict != 'true'` conjunct alone; the existing
`reuse != 'true'` (#499), stage and decide conjuncts stay on every step
that has them. The "single variable" claim is narrowed accordingly.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 24cd39fca5 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 65526a6a7f docs: spec #510 r3 — one branching variable for the gate outcome
Spec review r2: §5.2 named the gate outcome three different ways; the
skip branch (spec stage, reuse fast-path) would have matched a literal
`result != 'green'` and produced a spurious S7→S6 return. All step
conditions now branch on `proceed` only (true = green or skipped, false =
red/missing); `result` feeds the comment text alone.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] b593489041 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] a91ba16a5c docs: spec #510 r2 — gate after reuse, proof requires executed mutant jobs
Spec review r1: place the gate after the #499 reuse step so its output is
defined; a green dispatch counts only when the "Мутанты по диффу" jobs ran
and passed (a foreign dispatch with mutants=false leaves them skipped);
therefore the Validate job runs whenever mutants are requested, even on an
empty selection. Explicit User-Visible/UX/i18n N/A statement added.

Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] 1063156f12 docs: review document for #510
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
Codexandclaude[bot] 2b8b7b2aa7 docs: spec for #510 — mutants on the review candidate, review waits for a green Validate, handoff rules
Issue: #510
User-Visible: no
2026-09-09 15:14:20 +00:00
claude[bot] eed7239aa3 docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:45:45 +00:00
Codex 9d8f89d260 ci: release gate judges the latest non-cancelled Validate run of the SHA
The gate used to require every Validate run on the tag SHA to be green:
a cancelled duplicate or a red flake that a later re-run had fixed kept
the stable release blocked (v1.73.0, 09.09 — released by hand). Now the
verdict comes from the newest run that was not cancelled: not completed →
wait, success → pass, anything else → fail, no run → wait. The same rule
is documented for the perf workflow and the release runbook.

Mutants: release-gate-counts-cancelled-runs, release-gate-oldest-run-wins.

Issue: #511
User-Visible: no
2026-09-09 17:38:48 +03:00
claude[bot] 590b3a7e67 docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:27:52 +00:00
claude[bot] 6842b69eed docs: review document for #511
Issue: #511
User-Visible: no
2026-09-09 14:20:48 +00:00
Codex ceab345215 perf: give the Stage 3 dense isometric budget the same long-task count allowance (#507)
The #160 contract test keeps the dense profile's longTasks block equal to
the historical isometric one, and both profiles boot through the same lazy
iso-scene-render chunk; the accepted split applies to both. Validate
34356856702 caught the divergence.

Issue: #507
User-Visible: no
Release: v1.73.0
2026-09-09 16:30:18 +03:00
Codex d72cb704f3 perf: accept the lazy isometric chunk boot-task split in the isometric long-task count budget (#507)
Full Performance of the v1.73.0 stable candidate against the v1.72.0 product
(run 34354409872) was red on one check of the isometric profile:
longTask.countP95 16 → 20 against max(16×1.2, 16+3) = 19.2, with every
timing, longTask.totalP95Ms and longTask.maxSingleMs green. The trace behind
#506 shows why: since v1.73.0-beta.1 the isometric renderer is the lazy
iso-scene-render chunk (#160 Stage 3), so the single v1.72.0 boot task is
split in two around that import — the same work, +2 tasks.

Owner decision 2026-09-09: accept the split. countNoiseAllowance 3 → 5 for
large-house-isometric-v1 only; the ratio, the hard ceiling, total and
maximum single task keep gating real growth. The downloaded CI artefact
re-evaluated with this budget passes (limit 21, actual 20, no failures).
Documented in demo/performance/README.md; the budget test pins the
allowance and the untouched profiles.

Issue: #507
User-Visible: no
Release: v1.73.0
2026-09-09 16:24:15 +03:00
Codex 913e3187f1 Release v1.73.0
Promote the nine published v1.73.0 betas without new product behaviour:
stable version fields, synchronized generated bundles, the aggregated
bilingual release notes from v1.72.0 and status metadata only. beta.9
carried the startup-regression fix (#506) that Full Performance caught on
the first promotion attempt; the stable body keeps it out as an in-line
regression per the #328 curation rules.

Issue: #506
User-Visible: no
Release: v1.73.0
2026-09-09 15:22:01 +03:00
Codex 43809178ee test: poll smoke_preloader's reduced-motion phase from Playwright, with navigation diagnostics
The beta.9 candidate failed the same phase twice in CI with "Resulting
promise was garbage collected" (runs 34346813552, 34347910231) while it
passes locally; a timer guard did not help, which points at a destroyed
context rather than a starved animation-frame chain. The wait now runs as
page.waitForFunction with raf polling, and the smoke logs frame navigations
and page crashes as `diagnostic …` lines so the next failure names its
cause. Verdict unchanged (animationName of the boot house, or 'missed').
Pre-release gate repair per PROCESS §11.4; locally OK ×2.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 15:05:46 +03:00
Codex 581d165bda test: keep smoke_preloader's reduced-motion wait alive without animation frames
Validate 34346813552 on the beta.9 candidate failed only in browser smoke
shard 1: smoke_preloader phase 3 died with "Resulting promise was garbage
collected" — its rAF-only wait for the boot house had no other reference
while the runner withheld animation frames. A timer now bounds the wait
and keeps the promise reachable; the verdict is unchanged (animationName
of the house, or 'missed'). Pre-release gate repair per PROCESS §11.4:
locally `node demo/smoke_preloader.mjs` → OK ×2; all other heavy gates of
that run (golden, perf-smoke, backend, shards 2–3) were green.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 14:53:45 +03:00
Codex a9d0cab2dd Prepare v1.73.0-beta.9
Version fields and generated bundles move to 1.73.0-beta.9; the #506
changelog entry leaves Unreleased for the beta.9 section; release notes and
STATUS describe the startup-regression fix that unblocks the stable
promotion. No product change beyond #506, already reviewed and merged.

Issue: #506
User-Visible: no
Release: v1.73.0-beta.9
2026-09-09 14:40:28 +03:00
482 changed files with 67060 additions and 31107 deletions
+17 -24
View File
@@ -3,9 +3,14 @@ name: Анонс релиза
# Stable releases are announced; prereleases are deliberately silent.
# workflow_dispatch exists purely as a connectivity test button and therefore
# remains allowed to send a test message.
# #538: НЕТ триггера `release: published`. Три воркфлоу висели на одном событии
# и бежали параллельно, а анонсу нечего было проверять — он выигрывал гонку
# всегда. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт ассетов
# отказал: релиз остался без `houseplan-card.js`, E2E не выполнялся вовсе, а
# подписчики получили сообщение. Анонс вызывается только после того, как работа
# сделана: `release.yml` зовёт его после выкладки ассетов,
# `publish-prerelease.yml` — после публикации беты.
on:
release:
types: [published]
workflow_dispatch: {}
workflow_call:
inputs:
@@ -37,12 +42,12 @@ permissions:
jobs:
telegram:
name: Оповещение в Telegram (только стабильные)
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
runs-on: ubuntu-latest
steps:
- name: Check out release notes for a reusable call
if: ${{ inputs.reusable == true }}
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.ref }}
- name: Send to Telegram
@@ -54,32 +59,20 @@ jobs:
INPUT_NAME: ${{ inputs.release_name }}
INPUT_URL: ${{ inputs.url }}
INPUT_PRE: ${{ inputs.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_PRE: ${{ github.event.release.prerelease }}
# The body goes through env, never through shell interpolation —
# release notes are arbitrary text.
RELEASE_BODY: ${{ github.event.release.body }}
EVENT: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
else
if [ "$CALLED" = "true" ]; then
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
else
TAG=$RELEASE_TAG
NAME=$RELEASE_NAME
URL=$RELEASE_URL
PRE=$RELEASE_PRE
BODY=$RELEASE_BODY
fi
# Единственный путь к сообщению о релизе — вызов из воркфлоу, который
# уже закончил работу (#538). Тело берётся из файла заметок ветки
# тега, а не из события: события здесь больше нет.
TAG=$INPUT_TAG
NAME=$INPUT_NAME
URL=$INPUT_URL
PRE=$INPUT_PRE
BODY=$(cat docs/RELEASE-NOTES.md)
if [ "$PRE" = "true" ]; then
echo "Prerelease Telegram announcement is disabled"
exit 0
+4 -4
View File
@@ -33,10 +33,10 @@ jobs:
name: Съёмка и сверка скриншот-индекса
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -45,7 +45,7 @@ jobs:
# системные библиотеки Chromium уже в образе раннера.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
@@ -145,7 +145,7 @@ jobs:
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
fi
- name: Upload candidate
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docs-screenshots
path: |
+100 -33
View File
@@ -5,11 +5,12 @@ name: Мутационный гейт
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
# защитой, и это хуже его отсутствия.
#
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
# test/mutation-gate.test.mjs.
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
# Мутанты, задетые диффом, конвейер ревью гоняет отдельно на кандидате (#510).
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
# обычными юнитами: test/mutation-gate.test.mjs.
#
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
@@ -25,14 +26,15 @@ on:
required: false
default: dev
schedule:
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
- cron: '20 5 * * 1'
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
- cron: '0 1 * * *'
permissions:
contents: read
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
@@ -41,8 +43,30 @@ concurrency:
cancel-in-progress: true
jobs:
# #549: moving ref разрешается ровно один раз. Все шарды ниже получают один
# commit/tree, а не самостоятельно читают dev в разное время.
material:
name: "Зафиксировать неизменяемый материал"
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.identity.outputs.sha }}
tree: ${{ steps.identity.outputs.tree }}
ref: ${{ steps.identity.outputs.ref }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
fetch-depth: 0
- name: Зафиксировать commit и tree
id: identity
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
mutants:
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
needs: material
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -52,19 +76,19 @@ jobs:
# Час — потолок против зависшего Chromium.
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
ref: ${{ needs.material.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
@@ -78,7 +102,7 @@ jobs:
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
@@ -99,42 +123,85 @@ jobs:
# `tee` не съел код выхода раннера.
- name: Каждый тест ловит свою поломку
run: |
mkdir -p artifacts
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
set -o pipefail
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
- name: Сохранить лог шарда
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
- name: Записать identity шарда
if: always()
uses: actions/upload-artifact@v7
run: |
node scripts/mutation-gate-report.mjs \
--write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
--shard=${{ matrix.shard }} --shards=4
- name: Сохранить лог и identity шарда
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mutation-shard-${{ matrix.shard }}
path: artifacts/mutation-shard-${{ matrix.shard }}.log
name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
path: artifacts/mutation-shard-${{ matrix.shard }}
if-no-files-found: warn
retention-days: 30
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
# Результат нельзя приписывать material, пока не доказаны все четыре шарда.
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
evidence:
name: "Доказать единый material всех шардов"
needs: [material, mutants]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# CLI отчётчика принадлежит зафиксированному material. В main лежит
# только workflow и может не быть dev-версии scripts/**.
ref: ${{ needs.material.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Забрать evidence всех попыток
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: mutation-shard-*
path: artifacts/mutation-logs
- name: Проверить полноту и identity
run: |
node scripts/mutation-gate-report.mjs --verify-only \
--logs=artifacts/mutation-logs --shards=4 \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }}
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
# для отладки самого гейта, его результат смотрят в прогоне — issue на
# каждый такой отказ был бы шумом, который снова перестанут читать.
#
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
# validate.yml, job с actions: read): перечислены все три.
report:
name: "Отказ расписания: issue и Telegram"
needs: mutants
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
needs: [material, mutants, evidence]
if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success')
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
issues: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
- uses: actions/setup-node@v7
# Не перечитываем moving dev: код отчётчика берётся из уже
# зафиксированного material. В стабильном main dev-скриптов нет.
ref: ${{ needs.material.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Забрать логи шардов
uses: actions/download-artifact@v7
continue-on-error: true
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: mutation-shard-*
path: artifacts/mutation-logs
@@ -143,14 +210,14 @@ jobs:
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
# github.sha: для расписания это вершина default-ветки main, и отчёт
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
SHA=$(git rev-parse HEAD)
mkdir -p artifacts
node scripts/mutation-gate-report.mjs \
--logs=artifacts/mutation-logs --shards=4 \
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
--require-evidence --logs=artifacts/mutation-logs --shards=4 \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
--run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \
--body-out=artifacts/mutation-report.md \
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
+5 -5
View File
@@ -45,7 +45,7 @@ jobs:
- space-glow
steps:
- name: Check out candidate
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: candidate
fetch-depth: 2
@@ -131,12 +131,12 @@ jobs:
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
- name: Check out base SHA
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ steps.base.outputs.sha }}
path: baseline
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -150,7 +150,7 @@ jobs:
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
@@ -267,7 +267,7 @@ jobs:
- name: Upload full performance report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: full-performance-${{ matrix.profile }}
path: artifacts/performance
+58
View File
@@ -0,0 +1,58 @@
name: Сверка очереди ревью
run-name: "reconcile process queue · ${{ github.event_name }}"
on:
schedule:
- cron: '7,37 * * * *'
workflow_dispatch:
inputs:
apply:
description: "Повторно будить потерянные запросы и публиковать диагностику"
required: true
type: boolean
default: true
permissions:
actions: read
contents: read
issues: read
jobs:
reconcile:
name: "Один снимок S4/S7 без polling модели"
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: process-reconcile
cancel-in-progress: false
steps:
# Расписание читается из main, а исполняемый reconciler — из dev: так
# после штатного merge действует та же версия кода, которую проверил CI.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: dev
fetch-depth: 1
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Сопоставить labels, requests, runs и sealed evidence
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
REPO: ${{ github.repository }}
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
run: |
mkdir -p artifacts/process-reconcile
node scripts/process-reconcile.mjs \
--repo "$REPO" \
--apply="$APPLY" \
--max-actions=5 \
--output=artifacts/process-reconcile/summary.json
- name: Опубликовать компактный machine-readable итог
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
path: artifacts/process-reconcile/summary.json
if-no-files-found: error
retention-days: 14
+569 -67
View File
@@ -1,4 +1,5 @@
name: Ревью-конвейер
run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}"
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
# сообщение: она порождает событие, событие запускает следующий шаг.
@@ -28,16 +29,20 @@ on:
# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает
# ни одной job (`if` на guard) и в группу не входит.
# #556: права выдаются по job, а не одной строкой на весь workflow. Общий блок
# давал `issues: write` и OIDC даже той стадии, которая ничего не пишет, —
# модели. Ниже минимум на каждую: писать в issue умеют только детерминированные
# стадии, OIDC нужен исключительно `claude-code-action`.
permissions:
contents: read
issues: write
# Обязательно: claude-code-action получает OIDC-токен для авторизации
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
id-token: write
jobs:
guard:
name: "Страж: ребейз на dev и предпосылки ревью"
# Читает и переставляет метки, комментирует отказ.
permissions:
contents: read
issues: write
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
# события помечаются skipped и не занимают место в группе concurrency.
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
@@ -55,7 +60,7 @@ jobs:
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
# inline-shell не покрывается тестами (#454). Node на раннере
# предустановлен, setup-node не нужен.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 1
ref: dev
@@ -254,18 +259,46 @@ jobs:
echo "spent=$spent" >> "$GITHUB_OUTPUT"
echo "limit=$limit" >> "$GITHUB_OUTPUT"
review:
name: "Ревью (Claude): вердикт в issue"
prepare:
name: "Ревью: материал и deterministic gates"
# Ребейзит ветку задачи и комментирует возврат автору.
permissions:
contents: read
issues: write
needs: guard
if: needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
# Время — единственный настоящий ограничитель зациклившегося прогона.
timeout-minutes: 45
# Ожидание Validate не отнимает бюджет у модели (#551). Сам gate может
# ждать 45 минут; подготовке оставлен отдельный запас на checkout/rebase.
timeout-minutes: 55
outputs:
proceed: ${{ steps.gate.outputs.proceed }}
branch: ${{ steps.branch.outputs.name }}
rebase_note: ${{ steps.rebase.outputs.note }}
material_sha: ${{ steps.material.outputs.sha }}
material_tree: ${{ steps.material.outputs.tree }}
material_specs: ${{ steps.material.outputs.specs }}
material_issue_body: ${{ steps.material.outputs.issue_body }}
reuse: ${{ steps.reuse.outputs.reuse }}
reuse_doc: ${{ steps.reuse.outputs.doc }}
reuse_round: ${{ steps.reuse.outputs.round }}
reuse_tree: ${{ steps.reuse.outputs.tree }}
validate_result: ${{ steps.gate.outputs.result }}
validate_url: ${{ steps.gate.outputs.url }}
validated_note: ${{ steps.validated.outputs.note }}
spec_body_changed: ${{ steps.spec_body.outputs.changed }}
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
duration_seconds: ${{ steps.duration.outputs.seconds }}
steps:
- uses: actions/checkout@v7
- name: Начать измерение стадии
id: clock
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: dev
@@ -295,7 +328,7 @@ jobs:
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
# тяжёлых job, здесь его не было.
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -321,20 +354,10 @@ jobs:
git checkout -q "origin/$branch"
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
echo "name=$branch" >> "$GITHUB_OUTPUT"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
# ветку, и спрашивать его поздно.
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
# ребейза (#515): снятые здесь, они после force-push приведённой
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
# страховка #414 не находили дерева в свежем клоне.
else
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
@@ -357,6 +380,8 @@ jobs:
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
# #539: тем же токеном спрашивается REST — через него идёт и диспатч.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
# rebase, в отличие от commit, не принимает -c user.*: он запускает
# свои процессы и требует личность в окружении, иначе падает с
@@ -399,6 +424,25 @@ jobs:
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
fi
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
# нельзя. 12.09 на #536 диспатч, отправленный через три секунды после
# этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на
# материале и вернул задачу автору, которому чинить было нечего.
# Поэтому шаг не заканчивается, пока REST не отдаст новую вершину —
# именно REST, потому что через него же идёт и сам диспатч.
after=$(git rev-parse HEAD)
settled=false
for _ in $(seq 1 30); do
seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \
--jq .object.sha 2>/dev/null || true)
if [ "$seen" = "$after" ]; then settled=true; break; fi
sleep 2
done
if [ "$settled" != "true" ]; then
echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA"
exit 1
fi
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
# и без этого он ребейзил бы заново уже приведённое.
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
@@ -413,9 +457,46 @@ jobs:
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
env:
NUM: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "материал ревью: $(git rev-parse --short HEAD)"
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
# В шаге публикации дерево уже сброшено на целевую ветку, и
# спрашивать его поздно.
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
# где-нибудь достижим. Файлов может не быть (инфраструктурная
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
# длиннее одного.
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
| awk '{print $2" "$4}' | tr '\n' ';')
echo "specs=$specs" >> "$GITHUB_OUTPUT"
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
# Читается здесь, а не из github.event.issue.body: между событием
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
# Validate), и снимок события описывал бы не тот текст.
body=$(mktemp)
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
digest=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { readFileSync } = await import("node:fs");
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
});
' "$body")
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
echo "тело issue: ${digest:0:12}"
else
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
fi
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
@@ -431,8 +512,13 @@ jobs:
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
# Правка ТЗ между раундами обязана отменять повторное применение
# зелёного вердикта: иначе вызов модели пропускается и находку
# «ТЗ менялось» некому напечатать (#517 AC6).
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
run: |
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD)
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
--issue-body="${ISSUE_BODY}")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
@@ -474,6 +560,64 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
# запускает Validate с мутантами на материале и ждёт его. Красный или
# пропавший прогон возвращает задачу автору без ревью — цикл не
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
# слияние само дожидается Validate на кандидате.
- name: Validate с мутантами на материале
id: gate
if: steps.rebase.outputs.conflict != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
STAGE: ${{ needs.guard.outputs.stage }}
REUSE: ${{ steps.reuse.outputs.reuse }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
run: |
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
exit 0
fi
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
echo 'proceed=true' >> "$GITHUB_OUTPUT"
else
echo 'proceed=false' >> "$GITHUB_OUTPUT"
fi
- name: Validate красный — вернуть автору без ревью
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
BRANCH: ${{ steps.branch.outputs.name }}
SHA: ${{ steps.material.outputs.sha }}
RESULT: ${{ steps.gate.outputs.result }}
NOTE: ${{ steps.gate.outputs.note }}
URL: ${{ steps.gate.outputs.url }}
run: |
short=$(git rev-parse --short "$SHA")
cat > /tmp/gate.md <<EOF
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
Задача переведена в \`S6-in-progress\`. Осталось:
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
2. дождаться зелёного дешёвого Validate на пуше;
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
EOF
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
# ревью тратится на повторение CI вместо чтения кода.
@@ -483,7 +627,7 @@ jobs:
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
- name: Зелёные гейты на этом SHA
id: validated
if: steps.rebase.outputs.conflict != 'true'
if: steps.gate.outputs.proceed == 'true'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
run: |
@@ -509,24 +653,198 @@ jobs:
} >> "$GITHUB_OUTPUT"
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
# Это deterministic evidence для промпта, поэтому вычисляется до запуска
# модели и передаётся вместе с неизменяемым контрактом материала (#551).
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
id: spec_body
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
DIGEST: ${{ steps.material.outputs.issue_body }}
run: |
if [ -z "$DIGEST" ]; then
echo "хеша тела нет — сравнивать не с чем"
exit 0
fi
out=$(node -e '
import("./scripts/review-doc-guard.mjs").then(async (m) => {
const { execFileSync } = await import("node:child_process");
const [num, digest] = process.argv.slice(1);
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
const changed = m.issueBodyChanged(docs, digest);
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
else process.stdout.write("changed=false\n");
});
' "$NUM" "$DIGEST")
printf '%s\n' "$out"
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
- name: Собрать контракт материала между стадиями
id: prepared
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ steps.branch.outputs.name }}
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
VALIDATE_RESULT: ${{ steps.gate.outputs.result }}
VALIDATE_URL: ${{ steps.gate.outputs.url }}
REBASE_NOTE: ${{ steps.rebase.outputs.note }}
VALIDATED_NOTE: ${{ steps.validated.outputs.note }}
SPEC_BODY_CHANGED: ${{ steps.spec_body.outputs.changed }}
SPEC_BODY_DOC: ${{ steps.spec_body.outputs.doc }}
SPEC_BODY_RECORDED: ${{ steps.spec_body.outputs.recorded }}
run: |
dir="$RUNNER_TEMP/review-prepared"
mkdir -p "$dir"
jq -n -S \
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
--arg branch "$BRANCH" --arg material_sha "$MATERIAL_SHA" \
--arg material_tree "$MATERIAL_TREE" --arg material_specs "$MATERIAL_SPECS" \
--arg material_issue_body "$MATERIAL_ISSUE_BODY" \
--arg validate_result "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
--arg spec_body_changed "$SPEC_BODY_CHANGED" --arg spec_body_doc "$SPEC_BODY_DOC" \
--arg spec_body_recorded "$SPEC_BODY_RECORDED" \
'{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,cycle:$cycle,branch:$branch,material_sha:$material_sha,material_tree:$material_tree,material_specs:$material_specs,material_issue_body:$material_issue_body,validate_result:$validate_result,validate_url:$validate_url,rebase_note:$rebase_note,validated_note:$validated_note,spec_body_changed:$spec_body_changed,spec_body_doc:$spec_body_doc,spec_body_recorded:$spec_body_recorded}' \
> "$dir/prepared.json"
(cd "$dir" && sha256sum prepared.json > manifest.sha256)
echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: Передать подтверждённый материал модели
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.prepared.outputs.artifact }}
path: ${{ runner.temp }}/review-prepared
if-no-files-found: error
retention-days: 1
- name: Зафиксировать длительность подготовки
id: duration
if: always()
env:
STARTED: ${{ steps.clock.outputs.started }}
run: |
seconds=$(( $(date +%s) - STARTED ))
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
echo "- deterministic prerequisites: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
model_review:
name: "Ревью: работа модели"
# Единственная недоверенная стадия: модель читает материал и кладёт
# запечатанный artifact. Писать в issue и в репозиторий ей незачем — это
# делает `integrate`, проверив происхождение: в репозиторий модель не пишет
# вообще, а в issue — только своим комментарием и отдельным issue по §12.
#
# Права здесь реальны только вместе с `github_token` у шага Review. Без него
# claude-code-action меняет OIDC на собственный App-токен с дефолтом
# `contents/issues/pull_requests: write` (`src/github/token.ts`), и блок ниже
# не ограничивает ничего: токен `ghs_…` от claude[bot] лежит прямо в
# окружении Bash-инструмента модели — это поймало ревью r1 по #556 в
# собственной же сессии. С переданным `secrets.GITHUB_TOKEN` обмена не
# происходит, `id-token` больше не нужен, и этот список становится потолком.
#
# `issues: write` остаётся: процесс требует от ревьюера комментарий с
# вердиктом (§7.2) и отдельный issue на Medium вне скоупа (§12). Снять его
# можно только перенеся и то и другое в `integrate` — это отдельная правка
# конвейера, не эта задача. Что остаётся модели этим правом: комментарий,
# метки, правка тела issue. Чего не остаётся: запись в репозиторий, слияние
# (его решает запечатанный verdict.json в `integrate`), релиз.
permissions:
contents: read
issues: write
needs: [guard, prepare]
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
runs-on: ubuntu-latest
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
# Весь бюджет принадлежит модели и её локальным проверкам; ожидания Validate
# в этом job больше нет (#551).
timeout-minutes: 45
outputs:
duration_seconds: ${{ steps.duration.outputs.seconds }}
steps:
- name: Начать измерение стадии
id: clock
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
ref: ${{ needs.prepare.outputs.material_sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- name: Получить контракт подготовленного материала
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/review-prepared
- name: Проверить контракт и exact material
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ needs.prepare.outputs.branch }}
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
run: |
dir="$RUNNER_TEMP/review-prepared"
(cd "$dir" && sha256sum -c manifest.sha256)
jq -e \
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
--arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \
--arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \
--arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
--arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \
--arg spec_recorded "$SPEC_BODY_RECORDED" \
'.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \
"$dir/prepared.json"
test "$(git rev-parse HEAD)" = "$MATERIAL_SHA"
test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE"
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
- name: Установить зависимости
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
run: npm ci
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
# но когда нужен — качать его заново дороже, чем держать в кэше.
- name: Кэш браузеров Playwright
id: pw
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
if: steps.pw.outputs.cache-hit != 'true'
# Без --with-deps: системные библиотеки Chromium предустановлены в
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
@@ -542,7 +860,6 @@ jobs:
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
- name: Установить Claude Code детерминированно
id: claude_bin
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
run: |
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
@@ -559,8 +876,7 @@ jobs:
- name: Review
id: review
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
env:
# Вне рабочей копии: восстановление дерева ревьюером не должно
# уничтожать его собственный артефакт (#220).
@@ -569,6 +885,11 @@ jobs:
# Подписка, а не отдельный счёт API: токен выпускается через
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# Ambient job-scoped токен вместо App-обмена (#556). Со ним
# `permissions:` этой job — настоящий потолок прав модели: ни записи в
# репозиторий, ни постановки метки, ни комментария. Строку нельзя
# снять, не вернув модели право двигать процесс.
github_token: ${{ secrets.GITHUB_TOKEN }}
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
prompt: |
Ты ревьюер проекта House Plan. Язык ответа — русский.
@@ -584,7 +905,9 @@ jobs:
Номер захода нужен для имени документа — два документа с
одинаковым номером затёрли бы друг друга.
${{ steps.rebase.outputs.note }}
${{ needs.prepare.outputs.rebase_note }}
${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }}
**Если цикл не первый — объём разбора по дельте, а не заново**
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
@@ -631,8 +954,9 @@ jobs:
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
есть у старой задачи, он и есть материал, новые не создаются.
Проверь обязательные разделы §7.1, однозначность каждого AC и
указание способа доказательства. Отдельно проверь, что автор не
выдал догадку за решение: утверждение о поведении, которого нет ни
@@ -646,7 +970,7 @@ jobs:
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
`${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не
`${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
ним. Если автор в issue называет более новый коммит, которого в
@@ -666,7 +990,7 @@ jobs:
правке — не тщательность, а потеря времени: полные наборы это
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
${{ steps.validated.outputs.note }}
${{ needs.prepare.outputs.validated_note }}
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
и в повторном раунде тоже: код изменился, а стоят они минуты:
@@ -780,23 +1104,167 @@ jobs:
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
- name: Запечатать результат модели
id: result
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
SOURCE: ${{ runner.temp }}/review-document.md
OUT: ${{ steps.review.outputs.structured_output }}
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-document.md
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
run: |
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
legacy="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
if [ ! -f "$SOURCE" ] && [ -f "$legacy" ]; then cp "$legacy" "$SOURCE"; fi
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
printf '%s' "$OUT" > "$RUNNER_TEMP/verdict.json"
jq -e '
(.verdict == "green" or .verdict == "yellow" or .verdict == "red")
and (.high | type == "number") and (.medium | type == "number")
and (.summary | type == "string")' "$RUNNER_TEMP/verdict.json" >/dev/null
dir="$RUNNER_TEMP/review-result"
mkdir -p "$dir"
cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json"
cp "$SOURCE" "$dir/review-document.md"
cp "$RUNNER_TEMP/verdict.json" "$dir/verdict.json"
(cd "$dir" && sha256sum prepared.json review-document.md verdict.json > manifest.sha256)
echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- name: Передать результат интеграции
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ${{ steps.result.outputs.artifact }}
path: ${{ runner.temp }}/review-result
if-no-files-found: error
retention-days: 1
- name: Зафиксировать длительность модели
id: duration
if: always()
env:
STARTED: ${{ steps.clock.outputs.started }}
run: |
seconds=$(( $(date +%s) - STARTED ))
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
echo "- model review: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
integrate:
name: "Ревью: публикация и интеграция"
# Публикует разбор, переставляет метку, сливает проверенный материал.
permissions:
contents: read
issues: write
needs: [guard, prepare, model_review]
if: always() && needs.guard.outputs.stage != ''
runs-on: ubuntu-latest
concurrency:
group: process-issue-${{ github.event.issue.number }}
cancel-in-progress: false
# Слияние кандидата при ушедшем dev может само ждать Validate до 45 минут;
# оно не должно обрывать уже оплаченный model review (#551).
timeout-minutes: 55
steps:
- name: Начать измерение стадии
id: clock
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Проверить исходы предыдущих стадий
id: ready
env:
PREPARE_RESULT: ${{ needs.prepare.result }}
MODEL_RESULT: ${{ needs.model_review.result }}
PROCEED: ${{ needs.prepare.outputs.proceed }}
REUSE: ${{ needs.prepare.outputs.reuse }}
run: |
if [ "$PREPARE_RESULT" != "success" ]; then
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
exit 1
fi
if [ "$PROCEED" != "true" ]; then
echo "подготовка уже вернула задачу автору; интегрировать нечего"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
echo "::error::стадия model review завершилась: $MODEL_RESULT"
exit 1
fi
echo "proceed=true" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: steps.ready.outputs.proceed == 'true'
with:
fetch-depth: 0
ref: dev
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
if: steps.ready.outputs.proceed == 'true'
with:
node-version: 22
- name: Получить результат модели
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/review-result
- name: Проверить полноту и происхождение результата
id: result
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
# #556: единственное, что недоверенная стадия модели может передать
# дальше, — этот artifact, и принимается он как ввод противника: полный
# набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с
# тем, что посчитала детерминированная `prepare`. Проверка вынесена из
# inline-shell в `scripts/review-result-gate.mjs` ради враждебных
# фикстур — в YAML её нельзя прогнать ни одним отрицательным случаем.
env:
ISSUE: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ needs.prepare.outputs.branch }}
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
run: |
set -euo pipefail
dir="$RUNNER_TEMP/review-result"
node scripts/review-result-gate.mjs --dir="$dir"
printf 'structured_output<<EOF_RESULT\n' >> "$GITHUB_OUTPUT"
cat "$dir/verdict.json" >> "$GITHUB_OUTPUT"
# structured_output не обязан оканчиваться LF: delimiter команды
# GitHub должен начинаться с отдельной строки.
printf '\nEOF_RESULT\n' >> "$GITHUB_OUTPUT"
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
# решает этот шаг, а не модель: всё остальное откатывается.
- name: Опубликовать документ ревью
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
env:
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ needs.prepare.outputs.branch }}
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
SOURCE: ${{ runner.temp }}/review-result/review-document.md
# После ребейза конвейером — якоря приведённого материала (#515).
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
# Вердикт из structured_output попадает в блок якорей (#499): по нему
# следующий заход решает, можно ли применить зелёный вердикт повторно.
OUT: ${{ steps.review.outputs.structured_output }}
OUT: ${{ steps.result.outputs.structured_output }}
run: |
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
@@ -855,6 +1323,7 @@ jobs:
node scripts/review-doc-guard.mjs --anchor="$doc" \
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
--issue-body="$MATERIAL_ISSUE_BODY" \
--verdict="$verdict" --high="$high"
else
echo "::warning::$SOURCE не найден — документа для публикации нет"
@@ -945,12 +1414,12 @@ jobs:
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
# пользы нет — он достанет только то, что есть на origin.
- name: "Материал раунда воспроизводим (#413)"
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
env:
NUM: ${{ github.event.issue.number }}
STAGE: ${{ needs.guard.outputs.stage }}
CYCLE: ${{ needs.guard.outputs.cycle }}
BRANCH: ${{ steps.branch.outputs.name }}
BRANCH: ${{ needs.prepare.outputs.branch }}
run: |
marker=CODE-REVIEW
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
@@ -962,14 +1431,14 @@ jobs:
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
- name: Решение по вердикту
id: decide
if: steps.rebase.outputs.conflict != 'true'
if: steps.ready.outputs.proceed == 'true'
env:
OUT: ${{ steps.review.outputs.structured_output }}
OUT: ${{ steps.result.outputs.structured_output }}
STAGE: ${{ needs.guard.outputs.stage }}
REUSE: ${{ steps.reuse.outputs.reuse }}
REUSE_DOC: ${{ steps.reuse.outputs.doc }}
REUSE_ROUND: ${{ steps.reuse.outputs.round }}
REUSE_TREE: ${{ steps.reuse.outputs.tree }}
REUSE: ${{ needs.prepare.outputs.reuse }}
REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }}
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
run: |
@@ -1017,11 +1486,11 @@ jobs:
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
# случится (#364).
- name: dev ушёл вперёд, пока шло ревью
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
MATERIAL: ${{ steps.material.outputs.sha }}
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
GREEN: ${{ steps.decide.outputs.green }}
run: |
git fetch -q origin dev
@@ -1050,9 +1519,9 @@ jobs:
env:
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
BRANCH: ${{ needs.prepare.outputs.branch }}
NUM: ${{ github.event.issue.number }}
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
run: |
if [ -z "$BRANCH" ]; then
echo "::error::ветки задачи нет — сливать нечего"
@@ -1063,7 +1532,7 @@ jobs:
--issue="$NUM" --repo="${{ github.repository }}"
- name: Переставить метку
if: steps.rebase.outputs.conflict != 'true'
if: steps.ready.outputs.proceed == 'true'
env:
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
@@ -1080,18 +1549,51 @@ jobs:
--add-label "$TO" --remove-label "$FROM"
echo "$FROM -> $TO"
- name: Позвать владельца, если ревью упало
- name: Сводка длительности стадий
if: always()
env:
STARTED: ${{ steps.clock.outputs.started }}
PREPARE_SECONDS: ${{ needs.prepare.outputs.duration_seconds }}
MODEL_SECONDS: ${{ needs.model_review.outputs.duration_seconds }}
run: |
integration=$(( $(date +%s) - STARTED ))
{
echo "## Бюджеты стадий (#551)"
echo ""
echo "| Стадия | Длительность | Лимит |"
echo "|---|---:|---:|"
echo "| deterministic prerequisites | ${PREPARE_SECONDS:-нет полного измерения}s | 55 min |"
echo "| model review | ${MODEL_SECONDS:-не запускалась}s | 45 min |"
echo "| publication/integration | ${integration}s | 55 min |"
} >> "$GITHUB_STEP_SUMMARY"
- name: Позвать владельца, если стадия упала
if: failure()
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
PREPARE_RESULT: ${{ needs.prepare.result }}
MODEL_RESULT: ${{ needs.model_review.result }}
PROCEED: ${{ needs.prepare.outputs.proceed }}
REUSE: ${{ needs.prepare.outputs.reuse }}
run: |
stage="публикация/интеграция"
detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён."
if [ "$PREPARE_RESULT" != "success" ]; then
stage="deterministic prerequisites"
detail="Модель не запускалась, цикл ревью не израсходован."
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
stage="model review"
detail="Полного валидного результата модели нет; метка не менялась."
fi
# Тело через heredoc, а не многострочный --body: строка с нулевым
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
cat > /tmp/failure.md <<EOF
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
Автоматическое ревью не отработало: стадия **$stage** остановилась. [Прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
$detail
Если вердикт выше всё же опубликован — перестановку метки выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
EOF
gh issue comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" --body-file /tmp/failure.md
+113 -54
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
actions: read
issues: read
concurrency:
group: publish-prerelease-${{ inputs.tag }}
@@ -25,13 +26,13 @@ jobs:
sha: ${{ steps.candidate.outputs.sha }}
tag: ${{ steps.candidate.outputs.tag }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- name: Pin the current dev candidate
- name: Pin the dev candidate or the existing annotated tag
id: candidate
env:
TAG: ${{ inputs.tag }}
@@ -42,12 +43,23 @@ jobs:
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
exit 1
}
SHA=$(git rev-parse HEAD)
git fetch origin dev
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
DISPATCHED_SHA=$(git rev-parse HEAD)
git fetch --force origin dev --tags
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
SHA=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$SHA" || {
echo "::error::Existing remote tag $TAG is not annotated"
exit 1
}
else
SHA=$DISPATCHED_SHA
test "$(git rev-parse origin/dev)" = "$SHA" || {
echo "::error::The dispatched SHA is no longer the origin/dev tip"
exit 1
}
fi
git checkout --detach "$SHA"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Verify version, changelogs and bilingual release notes
@@ -77,6 +89,35 @@ jobs:
REPO: ${{ github.repository }}
SHA: ${{ steps.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
- name: Bind issue membership to the exact candidate
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.candidate.outputs.tag }}
SHA: ${{ steps.candidate.outputs.sha }}
run: |
set -euo pipefail
mkdir -p release-membership
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--dir release-membership --pattern RELEASE-MEMBERSHIP.json --clobber || true
fi
if [ -s release-membership/RELEASE-MEMBERSHIP.json ]; then
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
--input=release-membership/RELEASE-MEMBERSHIP.json
else
ISSUES=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--label S8-merged --limit 1000 --json number --jq 'map(.number)|join(",")')
node scripts/release-membership.mjs create --tag="$TAG" --candidate="$SHA" \
--issues="$ISSUES" --allow-unmatched \
--output=release-membership/RELEASE-MEMBERSHIP.json
fi
- name: Preserve candidate membership for publication
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-membership
path: release-membership/RELEASE-MEMBERSHIP.json
if-no-files-found: error
retention-days: 7
publish:
name: Публикация тега и релиза
@@ -86,15 +127,20 @@ jobs:
url: ${{ steps.verify.outputs.url }}
newly_published: ${{ steps.release.outputs.newly_published }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-membership
path: release-assets
- name: Build and verify both release assets before publication
env:
TAG: ${{ needs.gate.outputs.tag }}
SHA: ${{ needs.gate.outputs.sha }}
run: |
set -euo pipefail
npm ci
@@ -103,12 +149,20 @@ jobs:
npm run bundle:budget
VERSION=${TAG#v}
grep -RFq "$VERSION" dist
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
# #540: тот же способ, что у release.yml и release-prerelease.mjs —
# архив закоммиченного дерева точного коммита, детерминированный.
git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \
"$SHA:custom_components/houseplan"
node scripts/verify-houseplan-zip.mjs houseplan.zip \
custom_components/houseplan/frontend "$VERSION"
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
test -s houseplan.zip
mkdir -p release-assets
cp dist/houseplan-card.js houseplan.zip release-assets/
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
--input=release-assets/RELEASE-MEMBERSHIP.json
node scripts/release-assets.mjs sums release-assets --include-membership
- name: Create or verify the annotated tag
env:
TAG: ${{ needs.gate.outputs.tag }}
@@ -147,7 +201,22 @@ jobs:
fi
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
if [ "$WAS_DRAFT" = "false" ]; then
mkdir -p existing-public
if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir existing-public \
--pattern houseplan-card.js --pattern houseplan.zip \
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber \
&& diff -u release-assets/SHA256SUMS existing-public/SHA256SUMS \
&& node scripts/release-assets.mjs check existing-public release-assets/SHA256SUMS \
&& node scripts/release-membership.mjs verify --tag="$TAG" --candidate="${{ needs.gate.outputs.sha }}" \
--input=existing-public/RELEASE-MEMBERSHIP.json; then
echo "release is already public and byte-identical; publication skipped"
exit 0
fi
echo "existing public assets need recovery; verified files will be uploaded again"
fi
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
release-assets/RELEASE-MEMBERSHIP.json release-assets/SHA256SUMS \
--repo "$GITHUB_REPOSITORY" --clobber
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets,url)
@@ -156,7 +225,7 @@ jobs:
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
@@ -178,17 +247,26 @@ jobs:
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
throw new Error('release is not a public prerelease for the requested tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
# #540: публичные байты — ровно те, что собраны и проверены выше.
mkdir -p public
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
--pattern houseplan-card.js --pattern houseplan.zip \
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber
diff -u release-assets/SHA256SUMS public/SHA256SUMS
node scripts/release-assets.mjs check public release-assets/SHA256SUMS
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
--input=public/RELEASE-MEMBERSHIP.json
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
echo "url=$URL" >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n\n```\n%s```\n' \
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
- name: Verify HACS prerelease discovery order
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
with:
@@ -206,26 +284,31 @@ jobs:
);
}
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
# because a beta was published, not because someone remembered to do it. The
# manual step was skipped twice, and both times it broke the invariant that a
# closed issue carries no status label — the one thing `verify` relies on.
#
# A manual step after a successful release is the worst kind: by the time it is
# due, the work already looks finished, which is exactly why it gets forgotten.
# #547: bookkeeping is driven by the immutable candidate manifest, not by the
# mutable S8 queue. It also runs on a verified retry of an already-public beta.
close-merged:
name: Закрытие вошедших issue
needs: [gate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
# not start workflows, so removing the label cannot wake the review
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
issues: write
steps:
- name: Close the S8-merged queue and strip status labels
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.gate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-membership
path: release-membership
- name: Finish only the issues proven in the candidate manifest
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
@@ -233,33 +316,9 @@ jobs:
URL: ${{ needs.publish.outputs.url }}
run: |
set -euo pipefail
# Only the owner's issues take part in the process; issues filed by
# anyone else never carry status labels and are not ours to close.
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
--author Matysh --limit 100 --json number --jq '.[].number')
if [ -z "$numbers" ]; then
echo "the S8-merged queue is empty, nothing to close"
else
for n in $numbers; do
gh issue comment "$n" --repo "$REPO" \
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
# Label first, then close. If the run dies between the two steps an
# open issue without a status is visible and fixable in the flow;
# the reverse order would recreate the exact breakage this job is
# here to prevent.
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
gh issue close "$n" --repo "$REPO" --reason completed
echo "closed #$n"
done
fi
# Targeted at the defect that actually recurs, not at the invariant in
# general: no closed issue may still carry S8-merged.
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
--limit 100 --json number --jq 'length')
test "$leftover" = "0" || {
echo "::error::$leftover closed issues still carry S8-merged"
exit 1
}
node scripts/release-bookkeeping.mjs --repo="$REPO" --tag="$TAG" \
--candidate="${{ needs.gate.outputs.sha }}" --url="$URL" \
--membership=release-membership/RELEASE-MEMBERSHIP.json
announce:
name: Комментарий о публикации
-41
View File
@@ -1,41 +0,0 @@
name: HACS-zip к релизу
# hacs.json declares zip_release + filename=houseplan.zip, so every release
# (prereleases included) must carry the asset — HACS installs from it and
# GitHub's public download counter becomes a free per-version install metric
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
# dev, stable tags once it reaches main.
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
# once for the latest stable after the hacs.json change reaches main).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach the zip to"
required: true
permissions:
contents: write
jobs:
zip:
name: Собрать houseplan.zip и приложить к релизу
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
env:
EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
ref: ${{ steps.tag.outputs.tag }}
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
- name: Sanity check
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
- name: Upload asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
+409 -53
View File
@@ -1,99 +1,455 @@
name: "Релиз: ассеты после зелёной проверки"
name: "Релиз: проверка, сборка и публикация ассетов"
run-name: "Release ${{ inputs.tag || github.event.release.tag_name }}"
# #540: единственный путь, по которому установочные ассеты стабильного релиза
# (`houseplan.zip` для HACS и `houseplan-card.js` для ручной установки) попадают
# наружу. До этого публикаторов было четыре, и `release-zip.yml` выкладывал ZIP
# в ту же секунду, когда релиз становился публичным, — до Validate, Full
# Performance и E2E. Порядок теперь один: закрепить SHA → релиз в черновике →
# гейты на этом SHA → одна сборка и `SHA256SUMS` → загрузка в черновик →
# публикация → сверка публичных байтов с паспортом → анонс.
#
# Два входа, один порядок:
# • `workflow_dispatch(tag)` — штатный выпуск и ремонт. Тега ещё нет — он
# ставится на вершину ветки, с которой запущен workflow (main для
# стабильного, dev для беты). Тег есть — берётся его коммит.
# • `release: published` — человек опубликовал стабильный релиз руками.
# Fail-closed: релиз немедленно возвращается в черновик и проходит тот же
# путь; снаружи ничего установочного не остаётся, пока идут проверки.
# Беты это событие пропускают — у них свой staged-путь
# (`publish-prerelease.yml`, `release-prerelease.mjs`).
#
# Ремонт публичного релиза (dispatch на существующий тег): недостающие ассеты
# догружаются только при зелёных гейтах; присутствующий ассет с другим хешем —
# отказ без правок, публичные байты не подменяются молча.
#
# Событие `release` исполняет workflow с коммита тега: новая редакция файла
# действует для стабильных тегов только после того, как она есть на main.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Exact release tag, for example v1.75.1; created on the dispatched branch tip when missing"
required: true
type: string
permissions:
contents: write
actions: read
concurrency:
group: release-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
# workflow only built and uploaded, so an asset shipped while both Validate
# runs for the very same commit were red. The asset now waits for a green
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
#
# Needs a push with a token that has the `workflow` scope (the ordinary
# Personal Access Token used for `git push` refuses workflow file updates).
candidate:
name: "Кандидат: точный SHA, режим и черновик"
# Публикация беты руками — не наш случай: у бет свой staged-путь.
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.prerelease }}
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.resolve.outputs.sha }}
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.resolve.outputs.version }}
prerelease: ${{ steps.resolve.outputs.prerelease }}
mode: ${{ steps.release.outputs.mode }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Resolve the tag to its exact commit
id: resolve
env:
EVENT: ${{ github.event_name }}
TAG: ${{ inputs.tag || github.event.release.tag_name }}
run: |
set -euo pipefail
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || {
echo "::error::$TAG is not a release tag (vX.Y.Z or vX.Y.Z-pre)"
exit 1
}
VERSION=${TAG#v}
case "$TAG" in *-*) PRERELEASE=true ;; *) PRERELEASE=false ;; esac
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
# Peeled commit both for annotated and lightweight tags (a release
# form makes lightweight ones). Neither target_commitish nor the
# event SHA is trusted: the former may be a branch name.
SHA=$(git rev-list -n 1 "refs/tags/$TAG")
echo "tag $TAG exists → $SHA"
else
test "$EVENT" = "workflow_dispatch" || {
echo "::error::release event for a tag that does not exist: $TAG"
exit 1
}
SHA=$(git rev-parse HEAD)
echo "tag $TAG is new → dispatched branch tip $SHA"
fi
if [ "$PRERELEASE" = "false" ]; then
git fetch origin main
git merge-base --is-ancestor "$SHA" origin/main || {
echo "::error::stable candidate $SHA is not on main"
exit 1
}
else
git fetch origin dev
git merge-base --is-ancestor "$SHA" origin/dev || {
echo "::error::prerelease candidate $SHA is not on dev"
exit 1
}
fi
{
echo "sha=$SHA"
echo "tag=$TAG"
echo "version=$VERSION"
echo "prerelease=$PRERELEASE"
} >> "$GITHUB_OUTPUT"
- name: Take the release off the public surface until it is verified
id: release
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft > /tmp/is-draft 2>/dev/null; then
echo "mode=fresh" >> "$GITHUB_OUTPUT"
echo "no release for $TAG yet: it will be created as a draft after the gates"
elif [ "$(cat /tmp/is-draft)" = "true" ]; then
echo "mode=staged" >> "$GITHUB_OUTPUT"
echo "release $TAG is a draft: staging into it"
elif [ "$EVENT" = "release" ]; then
# Published by hand: nothing here has been verified. Back to draft
# first, gates second — the order is the whole point (#540).
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft
echo "mode=event" >> "$GITHUB_OUTPUT"
echo "::notice::$TAG was published by hand and is a draft again until the gates pass"
else
echo "mode=repair" >> "$GITHUB_OUTPUT"
echo "release $TAG is public: repair mode — only missing assets may be added"
fi
gate:
name: "Гейт: зелёная Проверка точного SHA тега"
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
needs: candidate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.release.tag_name }}
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`;
# без него зелёный Validate — прогон без смоков и golden. Трейлер обязан
# называть ровно этот тег: кандидат сам объявляет, чем он выпускается.
- name: Require the Release trailer naming this exact tag
env:
SHA: ${{ needs.candidate.outputs.sha }}
TAG: ${{ needs.candidate.outputs.tag }}
run: |
set -euo pipefail
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
if ! grep -Fxq "Release: $TAG" /tmp/head-message.txt; then
echo "::error::$SHA declares $(grep -E '^Release:' /tmp/head-message.txt | head -1), not $TAG"
exit 1
fi
- name: Verify version, changelogs and bilingual release notes
env:
TAG: ${{ needs.candidate.outputs.tag }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
else
node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" --stable
fi
- name: Require a green Validate for this exact commit
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
# HEAD is the peeled commit even when TAG is annotated. Do not trust
# target_commitish (it may be a branch name) or an event-context SHA.
SHA=$(git rev-parse HEAD)
echo "release tag: $TAG; exact commit: $SHA"
# #479: тяжёлые job Validate идут только на коммите с трейлером
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
exit 1
fi
node scripts/release-gate.mjs "$SHA"
SHA: ${{ needs.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA"
- name: Require full performance for a stable release
if: ${{ !github.event.release.prerelease }}
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
build:
name: Сборка бандла и загрузка ассетов
needs: gate
SHA: ${{ needs.candidate.outputs.sha }}
run: node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
# #514/#540: единственная проверка на настоящем Home Assistant. Раньше
# houseplan-e2e ставил `houseplan.zip` из публичного релиза — то есть
# релиз должен был быть публичным ДО проверки. Теперь он ставит дерево
# `custom_components/houseplan` коммита-кандидата (tarball codeload),
# а ZIP строится `git archive` из того же дерева: тождество «что
# тестировали = что публикуем» — хеш дерева, он печатается на сборке.
# Cross-repository dispatch needs a token with Actions: write on
# houseplan-e2e; HP_PROCESS_TOKEN (classic, repo scope) has it,
# E2E_DISPATCH_TOKEN is the fallback for a fine-grained token.
- name: Require green E2E on a real Home Assistant for a stable release
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
env:
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
SHA: ${{ needs.candidate.outputs.sha }}
TAG: ${{ needs.candidate.outputs.tag }}
run: node scripts/e2e-gate.mjs --ref="$SHA" --tag="$TAG"
stage:
name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"
needs: [candidate, gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.release.tag_name }}
- uses: actions/setup-node@v7
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- run: npm ci && npm run build
- name: Build once and verify both installable assets
id: build
env:
SHA: ${{ needs.candidate.outputs.sha }}
VERSION: ${{ needs.candidate.outputs.version }}
run: |
set -euo pipefail
npm ci
npm run build
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:budget
grep -RFq "$VERSION" dist
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
# The ZIP is the committed integration tree of the exact commit —
# the same tree E2E installed from the codeload tarball. `git archive`
# is deterministic for a commit, so a repair rebuilds identical bytes.
git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \
"$SHA:custom_components/houseplan"
node scripts/verify-houseplan-zip.mjs houseplan.zip \
custom_components/houseplan/frontend "$VERSION"
mkdir -p release-assets
cp dist/houseplan-card.js houseplan.zip release-assets/
node scripts/release-assets.mjs sums release-assets
TREE=$(git rev-parse "$SHA:custom_components/houseplan")
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
printf '### Staged assets for %s\n\n- exact commit: `%s`\n- `custom_components/houseplan` tree (what E2E installed): `%s`\n\n```\n%s```\n' \
"$VERSION" "$SHA" "$TREE" "$(cat release-assets/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
- name: Verify compositor frame continuity for a stable release
if: ${{ !github.event.release.prerelease }}
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
run: |
npx playwright install --with-deps chromium
node scripts/bundle-sync.mjs
npm run continuity:screencast
- name: Upload failed continuity frames
if: ${{ failure() && !github.event.release.prerelease }}
uses: actions/upload-artifact@v7
if: ${{ failure() && needs.candidate.outputs.prerelease != 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: continuity-screencast
path: artifacts/continuity-screencast
- name: Verify the complete committed frontend tree
run: |
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
test -s dist/houseplan-card.js
test -s dist/houseplan-panel.js
- name: Attach card to release
uses: softprops/action-gh-release@v3
- name: Keep the passport for the publication step
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
files: dist/houseplan-card.js
name: release-assets-${{ needs.candidate.outputs.tag }}
path: release-assets/SHA256SUMS
if-no-files-found: error
# Also for a draft made in the release form: its tag may not exist yet,
# and publishing such a draft would let GitHub tag target_commitish —
# not necessarily the verified commit. The tag is pinned here, first.
- name: Create or verify the tag at the exact commit
env:
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
run: |
set -euo pipefail
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
if [ -n "$REMOTE" ]; then
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
test -n "$PEELED" || PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG" '$2 == ref {print $1}')
test "$PEELED" = "$SHA" || {
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
exit 1
}
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" "$SHA" -m "$TAG"
git push origin "$TAG"
fi
- name: Stage the verified assets into the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
MODE: ${{ needs.candidate.outputs.mode }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$MODE" = "fresh" ]; then
FLAG="--prerelease=false"
if [ "$PRERELEASE" = "true" ]; then FLAG="--prerelease"; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --draft "$FLAG" \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
fi
if [ "$MODE" = "repair" ]; then
# Public release: what is already outside must be the bytes we just
# rebuilt; anything else is a finding, not a --clobber. Only missing
# assets are added, and only now — after the gates.
mkdir -p public
for name in $(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name'); do
case "$name" in
houseplan-card.js|houseplan.zip|SHA256SUMS)
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public --pattern "$name" --clobber ;;
esac
done
if [ -f public/SHA256SUMS ]; then
diff -u public/SHA256SUMS release-assets/SHA256SUMS || {
echo "::error::public SHA256SUMS of $TAG differ from the rebuilt assets"
exit 1
}
fi
node scripts/release-assets.mjs check public release-assets/SHA256SUMS --allow-missing
missing=""
for name in houseplan-card.js houseplan.zip SHA256SUMS; do
[ -f "public/$name" ] || missing="$missing release-assets/$name"
done
if [ -z "$missing" ]; then
echo "nothing to repair: every asset of $TAG is present and matches"
else
echo "adding missing assets:$missing"
# shellcheck disable=SC2086
gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY"
fi
else
# Draft: whatever a hand-made publication put here was never
# verified, so the verified bytes replace it.
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
release-assets/SHA256SUMS --repo "$GITHUB_REPOSITORY" --clobber
fi
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json tagName,isDraft,assets)
export RELEASE_JSON TAG
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
publish:
name: "Публикация и сверка публичных байтов"
needs: [candidate, gate, stage]
runs-on: ubuntu-latest
outputs:
url: ${{ steps.verify.outputs.url }}
name: ${{ steps.verify.outputs.name }}
newly_published: ${{ steps.flip.outputs.newly_published }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.candidate.outputs.sha }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: release-assets-${{ needs.candidate.outputs.tag }}
path: passport
- name: Publish the verified draft
id: flip
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
MODE: ${{ needs.candidate.outputs.mode }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$MODE" = "repair" ]; then
echo "newly_published=false" >> "$GITHUB_OUTPUT"
echo "repair of a public release: nothing to publish"
exit 0
fi
FLAG="--prerelease=false"
if [ "$PRERELEASE" = "true" ]; then FLAG="--prerelease"; fi
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false "$FLAG" \
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
echo "newly_published=true" >> "$GITHUB_OUTPUT"
- name: Verify the public release against the passport
id: verify
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.candidate.outputs.tag }}
SHA: ${{ needs.candidate.outputs.sha }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
run: |
set -euo pipefail
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,name,isDraft,isPrerelease,assets,url)
export RELEASE_JSON TAG PRERELEASE
node <<'NODE'
const release = JSON.parse(process.env.RELEASE_JSON);
if (release.tagName !== process.env.TAG || release.isDraft) throw new Error('release is not public for the requested tag');
if (String(release.isPrerelease) !== process.env.PRERELEASE) throw new Error('release prerelease flag does not match the tag');
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) {
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
}
NODE
# The bytes anyone downloads now are the bytes the gates saw.
mkdir -p public
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
--pattern houseplan-card.js --pattern houseplan.zip --pattern SHA256SUMS --clobber
diff -u passport/SHA256SUMS public/SHA256SUMS
node scripts/release-assets.mjs check public passport/SHA256SUMS
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git rev-list -n 1 "refs/tags/$TAG")" = "$SHA"
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
NAME=$(node -p "JSON.parse(process.env.RELEASE_JSON).name || process.env.TAG")
{
echo "url=$URL"
echo "name=$NAME"
} >> "$GITHUB_OUTPUT"
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub release](%s)\n\n```\n%s```\n' \
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
announce:
# #538: анонс — последнее звено, а не параллельное. Пока он висел на самом
# событии `release: published`, он обгонял гейт: 12.09 v1.75.0 объявили в
# канале в ту же минуту, когда проверка отказала выкладывать ассеты.
# `needs: publish` означает, что молчание — это тоже ответ: красный гейт или
# несостоявшаяся выкладка сообщения не рождают. Ремонт не анонсируется.
name: Оповещение о релизе после выкладки
needs: [candidate, publish]
if: ${{ needs.publish.outputs.newly_published == 'true' }}
uses: ./.github/workflows/announce.yml
with:
reusable: true
tag: ${{ needs.candidate.outputs.tag }}
release_name: ${{ needs.publish.outputs.name }}
url: ${{ needs.publish.outputs.url }}
prerelease: ${{ needs.candidate.outputs.prerelease == 'true' }}
ref: ${{ needs.candidate.outputs.tag }}
secrets: inherit
hacs-discovery:
name: HACS-видимость пре-релиза (порядок бет)
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
# sorting SemVer. A valid asset can therefore be invisible to beta users
# (beta.10 appeared after beta.9). Keep the release asset, but
# make that distribution failure impossible to miss in the release run.
if: ${{ github.event.release.prerelease }}
needs: build
if: ${{ needs.candidate.outputs.prerelease == 'true' }}
needs: [candidate, publish]
runs-on: ubuntu-latest
steps:
- name: Verify the published tag is the prerelease HACS will discover
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
EXPECTED_TAG: ${{ needs.candidate.outputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
@@ -102,7 +458,7 @@ jobs:
per_page: 100,
});
const first = releases.find((r) => r.prerelease && !r.draft);
const expected = context.payload.release.tag_name;
const expected = process.env.EXPECTED_TAG;
if (first?.tag_name !== expected) {
core.setFailed(
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
+266 -80
View File
@@ -18,9 +18,15 @@ on:
workflow_dispatch:
inputs:
full:
description: 'Полный набор: смоки, golden, performance_smoke'
description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу'
type: boolean
default: true
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
# кандидата запускают `-f full=false -f mutants=true` на материале.
mutants:
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
type: boolean
default: false
# A new push supersedes an unfinished validation for the same branch or PR.
# Exact-SHA release gates never depend on an obsolete commit.
@@ -41,7 +47,7 @@ jobs:
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
# действовать — на уровне шагов, с той же гранулярностью в логе.
preflight:
name: "Предполётные проверки: документация, провенанс, процесс"
name: "Предполёт: документация, провенанс, процесс"
runs-on: ubuntu-latest
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
# проверка 8 процессного гейта. Права перечислены явно, потому что job
@@ -59,9 +65,9 @@ jobs:
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
# работают как раньше. Единственная догрузка по требованию здесь —
# `git show origin/main:.github/workflows/process.yml`: один блоб.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
@@ -169,12 +175,22 @@ jobs:
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
# втором».
# #556: перемещаемая ссылка в `uses:` — доверие чужому владельцу тега
# здесь и сейчас, а не коду, который читали. Проверка стоит в preflight:
# она стоит миллисекунды и ловит правку, которая иначе доехала бы до
# привилегированной стадии.
- name: "Сторонние Actions закреплены полным SHA"
id: action_pins
continue-on-error: true
run: node scripts/action-pins.mjs
- name: Вердикт предполётных проверок
env:
DOCS: ${{ steps.docs.outcome }}
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
PROVENANCE: ${{ steps.provenance.outcome }}
PROCESS_GATE: ${{ steps.process_gate.outcome }}
ACTION_PINS: ${{ steps.action_pins.outcome }}
run: |
fail=0
check() {
@@ -191,6 +207,7 @@ jobs:
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
check "провенанс коммитов" "$PROVENANCE"
check "процессный гейт" "$PROCESS_GATE"
check "пины сторонних Actions" "$ACTION_PINS"
exit $fail
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
@@ -210,6 +227,7 @@ jobs:
actions: read
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
geometry_parity: ${{ steps.classify.outputs.geometry_parity }}
backend: ${{ steps.classify.outputs.backend }}
integration: ${{ steps.classify.outputs.integration }}
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
@@ -218,15 +236,15 @@ jobs:
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
heavy: ${{ steps.heavy.outputs.heavy }}
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
base: ${{ steps.base.outputs.base }}
# Разные вещи под разными именами намеренно: `base` — до какого коммита
# классифицировать файлы ветки (#387), `range_base` — от какого судить
# диапазон на dev (#388): общее имя — чужая база у потребителя.
# `base` — до какого коммита классифицировать файлы ветки (#387),
# `range_base` — от какого судить диапазон на dev (#388).
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
@@ -241,6 +259,7 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
MUTANTS_INPUT: ${{ inputs.mutants }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
@@ -363,15 +382,32 @@ jobs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
geometry_parity: ${{ steps.probe.outputs.geometry_parity }}
backend: ${{ steps.probe.outputs.backend }}
smoke_key: ${{ steps.keys.outputs.smoke }}
golden_key: ${{ steps.keys.outputs.golden }}
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
geometry_parity_key: ${{ steps.keys.outputs.geometry_parity }}
backend_key: ${{ steps.keys.outputs.backend }}
smoke_source_run: ${{ steps.p_smoke.outputs.source_run }}
smoke_source_attempt: ${{ steps.p_smoke.outputs.source_attempt }}
smoke_source_sha: ${{ steps.p_smoke.outputs.source_sha }}
golden_source_run: ${{ steps.p_golden.outputs.source_run }}
golden_source_attempt: ${{ steps.p_golden.outputs.source_attempt }}
golden_source_sha: ${{ steps.p_golden.outputs.source_sha }}
performance_smoke_source_run: ${{ steps.p_perf.outputs.source_run }}
performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }}
performance_smoke_source_sha: ${{ steps.p_perf.outputs.source_sha }}
geometry_parity_source_run: ${{ steps.p_geometry_parity.outputs.source_run }}
geometry_parity_source_attempt: ${{ steps.p_geometry_parity.outputs.source_attempt }}
geometry_parity_source_sha: ${{ steps.p_geometry_parity.outputs.source_sha }}
backend_source_run: ${{ steps.p_backend.outputs.source_run }}
backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }}
backend_source_sha: ${{ steps.p_backend.outputs.source_sha }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- name: Ключи переиспользования
id: keys
@@ -379,7 +415,7 @@ jobs:
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
run: |
for job in smoke golden performance_smoke backend; do
for job in smoke golden performance_smoke geometry_parity backend; do
key=$(node scripts/gate-reuse.mjs --job="$job")
echo "$job=$key" >> "$GITHUB_OUTPUT"
echo "$job: $key"
@@ -390,42 +426,81 @@ jobs:
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
echo "performance_smoke set: $set"
# lookup-only: маркер только проверяется, но не восстанавливается —
# сохранять его в этой job нечего, она ничего не прогоняла.
# Маркер восстанавливается во временный файл: #541 требует не только
# cache-hit, но и SHA/run исходного успешного job. Между lookup файл
# удаляется, чтобы один маркер не был принят за другой.
- name: Маркер smoke
id: m_smoke
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
lookup-only: true
- name: Доказательство источника smoke
id: p_smoke
if: steps.m_smoke.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер smoke перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер golden
id: m_golden
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-golden-${{ steps.keys.outputs.golden }}
lookup-only: true
- name: Доказательство источника golden
id: p_golden
if: steps.m_golden.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер golden перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер performance_smoke
id: m_perf
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
lookup-only: true
- name: Доказательство источника performance_smoke
id: p_perf
if: steps.m_perf.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер performance_smoke перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер geometry_parity
id: m_geometry_parity
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-geometry_parity-${{ steps.keys.outputs.geometry_parity }}
- name: Доказательство источника geometry_parity
id: p_geometry_parity
if: steps.m_geometry_parity.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер geometry_parity перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер backend
id: m_backend
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-backend-${{ steps.keys.outputs.backend }}
lookup-only: true
- name: Доказательство источника backend
id: p_backend
if: steps.m_backend.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер backend
if: always()
run: rm -f .reuse-marker
- name: Что переиспользуем
id: probe
env:
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
PERF: ${{ steps.m_perf.outputs.cache-hit }}
GEOMETRY_PARITY: ${{ steps.m_geometry_parity.outputs.cache-hit }}
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
run: |
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
@@ -444,6 +519,7 @@ jobs:
waive smoke "$SMOKE"
waive golden "$GOLDEN"
waive performance_smoke "$PERF"
waive geometry_parity "$GEOMETRY_PARITY"
waive backend "$BACKEND"
hacs:
@@ -452,9 +528,9 @@ jobs:
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: HACS validation
uses: hacs/action@main
uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa # main@2026-09-13
with:
category: integration
@@ -464,9 +540,9 @@ jobs:
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master
uses: home-assistant/actions/hassfest@58bff37c8947f690ace498be413a9b78d6f30f93 # master@2026-09-13
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
# файл, который он патчит, либо изменился его гард. `--check` этого не
@@ -476,46 +552,40 @@ jobs:
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
# детерминированных непересекающихся шарда вместо ослабления проверки.
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
# её исход по job, и skipped был бы неотличим от «не запрашивали».
changed_mutants:
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
needs: changes
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
if: needs.changes.outputs.mutants_requested == 'true'
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4, 5, 6]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/setup-python@v7
with:
python-version: '3.14'
- name: Backend test dependencies
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (файлы патча и гарда, без строки версии) повторно не гоняется.
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
# пропуск станет известен только после установки окружения.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: artifacts/mutation-ledger
# Cache immutable: rerun с тем же run_id обязан получить новый key,
@@ -524,7 +594,13 @@ jobs:
restore-keys: |
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
mutation-ledger-${{ matrix.shard }}-
- name: Затронутые мутанты ловятся
# #518: план шарда считается до установки окружения. npm ci, Python с
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
# их платили даже тогда, когда гонять было нечего: правка задела чужой
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
# git и node из образа.
- name: План шарда
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
@@ -546,11 +622,45 @@ jobs:
fi
if [ -z "$base" ]; then
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
echo "count=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "диапазон: $base..$HEAD_SHA"
echo "base=$base" >> "$GITHUB_OUTPUT"
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
echo "к прогону в этом шарде: ${count:-0}"
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
- if: steps.plan.outputs.count != '0'
run: npm ci
- if: steps.plan.outputs.count != '0'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
- name: Backend test dependencies
if: steps.plan.outputs.count != '0'
run: pip install -r tests_backend/requirements.txt
- name: Кэш браузеров Playwright
id: pw
if: steps.plan.outputs.count != '0'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Затронутые мутанты ловятся
if: steps.plan.outputs.count != '0'
env:
BASE: ${{ steps.plan.outputs.base }}
HEAD_SHA: ${{ github.sha }}
SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
@@ -559,7 +669,7 @@ jobs:
# попытку этого run либо самый новый журнал шарда.
- name: Сохранить журнал свидетелей
if: always()
uses: actions/cache/save@v6
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: artifacts/mutation-ledger
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
@@ -572,9 +682,9 @@ jobs:
steps:
# Полная история без блобов (#342): гейт «новый код не добавляет any»
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -632,7 +742,7 @@ jobs:
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
# дерева, и каждая браузерная job делает это перед первым кадром.
- name: Выложить бандл для браузерных job
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: card-bundle
path: dist/
@@ -642,7 +752,7 @@ jobs:
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
# входит в production-бандл и потому едет отдельным артефактом (#336).
- name: Выложить тестовые модули для смоков
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: card-test-build
path: test-build/
@@ -664,8 +774,8 @@ jobs:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -677,7 +787,7 @@ jobs:
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
@@ -688,12 +798,12 @@ jobs:
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
# сверяет вшитый отпечаток с исходниками этого дерева.
- name: Забрать собранный бандл
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
- name: Забрать тестовые модули
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-test-build
path: test-build
@@ -747,7 +857,7 @@ jobs:
exit $fail
- name: Upload smoke logs
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: smoke-logs-${{ matrix.shard }}
path: /tmp/smoke-logs
@@ -765,8 +875,9 @@ jobs:
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@v6
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
@@ -783,8 +894,8 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -796,7 +907,7 @@ jobs:
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
@@ -807,7 +918,7 @@ jobs:
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
# сверяет вшитый отпечаток с исходниками этого дерева.
- name: Забрать собранный бандл
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
@@ -817,7 +928,7 @@ jobs:
# прогона, потому что после падения шаги кэша уже не помогут отличить
# виновника от свидетеля.
- name: Прежнее падение на тех же входах
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
@@ -833,7 +944,7 @@ jobs:
fi
- name: Upload golden candidates/diffs
if: failure() || steps.golden.outputs.has_baselines == 'false'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: golden-images
path: artifacts/golden
@@ -852,7 +963,7 @@ jobs:
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
# это тем более не отказ гейта — job и так красная по существу.
continue-on-error: true
uses: actions/cache/save@v6
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .fail-marker
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
@@ -863,8 +974,9 @@ jobs:
printf '%s\n' "golden прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@v6
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
@@ -886,8 +998,8 @@ jobs:
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
@@ -899,7 +1011,7 @@ jobs:
# внятной ошибкой — тогда флаг вернуть.
- name: Кэш браузеров Playwright
id: pw
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
@@ -910,7 +1022,7 @@ jobs:
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
# сверяет вшитый отпечаток с исходниками этого дерева.
- name: Забрать собранный бандл
uses: actions/download-artifact@v7
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
@@ -946,7 +1058,7 @@ jobs:
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
- name: Upload performance smoke report
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: performance-smoke
path: artifacts/performance-smoke
@@ -957,8 +1069,9 @@ jobs:
printf '%s\n' "performance_smoke прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@v6
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
@@ -967,18 +1080,52 @@ jobs:
path: .reuse-marker
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
geometry_parity:
name: "Геометрия: TS/Python parity исполнена"
needs: [changes, reuse]
if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version-file: .python-version
- run: npm ci
- name: Компилировать узкое TS-зеркало П1–П4
run: |
npx --no-install tsc -p tsconfig.junction-parity.json
node scripts/fix-test-build.mjs
- name: Сравнить настоящие TS/Python validators (fail closed)
run: python tests_backend/junction_parity.py --build-dir=test-build/junction-parity
- name: Записать маркер успеха
run: |
printf '%s\n' "geometry_parity прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
continue-on-error: true
with:
path: .reuse-marker
key: reuse-geometry_parity-${{ needs.reuse.outputs.geometry_parity_key }}
backend:
name: "Бэкенд: pytest в Home Assistant"
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# Browser fixtures are generated by their real ESM factories and then
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/setup-python@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with: { python-version: "3.14" }
- run: |
# Точные версии — в tests_backend/requirements.txt (#392): без них
@@ -1026,7 +1173,7 @@ jobs:
echo "coverage: $actual% (baseline $baseline%)"
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
- name: Артефакт coverage.xml
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: backend-coverage
path: coverage.xml
@@ -1037,8 +1184,9 @@ jobs:
printf '%s\n' "backend прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@v6
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
@@ -1046,3 +1194,41 @@ jobs:
with:
path: .reuse-marker
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
# #541: общий conclusion workflow не говорит, какие условные job реально
# исполнились, а какие были законно переиспользованы. Этот всегда исполняемый
# финальный job публикует неизменяемый proof, привязанный к SHA/tree,
# run_id/run_attempt и фактическим outputs всех prerequisite job. Consumers
# review/merge/release принимают Validate только через scripts/ci-proof.mjs.
proof:
name: "Доказательство выполненных проверок"
if: always()
needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- name: Зафиксировать tree кандидата
id: candidate
run: echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- name: Собрать единый CI proof
env:
CANDIDATE_SHA: ${{ github.sha }}
CANDIDATE_TREE: ${{ steps.candidate.outputs.tree }}
CI_RUN_ID: ${{ github.run_id }}
CI_RUN_ATTEMPT: ${{ github.run_attempt }}
CI_EVENT: ${{ github.event_name }}
REQUEST_FULL: ${{ inputs.full }}
REQUEST_MUTANTS: ${{ inputs.mutants }}
NEEDS_JSON: ${{ toJSON(needs) }}
run: node scripts/ci-proof.mjs --emit=artifacts/ci-proof/proof.json
- name: Опубликовать proof точной попытки
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: artifacts/ci-proof/proof.json
retention-days: 30
if-no-files-found: error
+3 -1
View File
@@ -1,4 +1,6 @@
node_modules/
# Без слэша: с ним шаблон не покрывает СИМВОЛИЧЕСКУЮ ССЫЛКУ с этим именем,
# и `git add -A` в чужом worktree затягивает её в коммит (#520, прежде #429).
node_modules
tsout/
test-build/
*.log
+111 -45
View File
@@ -38,8 +38,11 @@ task records: problem, scope, acceptance criteria and discussion.
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
issue. Labels are the whole of it: GitHub Projects is no longer used.
of a status and `rejected` on a closed issue. A product issue in flight carries
exactly one `S*` label. An infrastructure-only issue is the deliberate exception:
it may carry no `S*` label while being implemented, enters the common flow at
`S7-code-review`, and from then on carries exactly one. Labels are the whole of
it: GitHub Projects is no longer used.
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
issue #338). `small` — the spec lives in the issue body and its review is a
@@ -53,14 +56,16 @@ where a criterion is broken plainly and saying which one is easy.
`S5-ready`, with the AC written into the issue body first. `trivial` requires a bug confined to one surface with no new UX
contract, no migration, no i18n, no perf or touch impact, at most three checkable
AC, **and expected behaviour already on record** — nothing left to decide. Code
review is never skipped on either track; it is what stands in for testing.
review is never skipped on either track: it checks scope, risks and the evidence
from executed tests, but does not stand in for executing them.
`PROCESS.md` §5 and §5.1 hold the criteria.
An issue filed by an outsider is worked exactly like one of the owner's own, once
the owner has decided to take it. The check sits **at the entrance**, not on every
step: while an issue carries no status label it is outside the process and the
invariants do not apply to it; once a label is on, the task is in flight and **who
filed it stops mattering**.
the owner has decided to take it. For product work the check sits **at the
entrance**, not on every step: the first status label admits it to the flow. For
infrastructure work an explicit assignment by the owner is the entrance, and the
issue may remain without an `S*` label until its first `S7-code-review`. In either
case, **who filed it stops mattering** once the owner has admitted it.
Applying that first label *is* the owner's explicit decision, and the platform
already guarantees it — only someone with write access can label. The earlier rule
@@ -185,32 +190,36 @@ way. The layout is therefore fixed:
or the owner — never reset or clean them away.
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
and publishes through the GitHub API; it makes no local commits at all, so it
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
pruned after use.
- **The reviewer owns no author tree.** It runs in CI on a fresh checkout. The
agent implementing an infrastructure task is an ordinary task author and uses
the same author-tree rules as product work; task branches must not share a
mutable checkout concurrently.
A worktree is only usable on the machine that created it: the `.git` file records
an absolute path in that machine's format. One created from a Linux sandbox is
dead on Windows and vice versa — create worktrees on the machine that will use
them, which for `hp-dev` means the owner's.
## Two-agent workflow
## Agent-neutral workflow
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
code and owns infrastructure and distribution. The owner rules on disputes, closes
issues and commands releases.
No task type is reserved for Codex, Claude or any other named model. **Any agent
may take any task**: analysis, spec, product implementation, infrastructure or a
release explicitly commanded by the owner. Roles describe the current artifact,
not the agent brand. The owner rules on product disputes, closes issues and
commands releases.
Author and reviewer are different models, which is what "a fresh session without
implementation context" means in practice. The reviewer never edits product code;
the author never grades their own work.
Author and reviewer are independent agents/sessions. They need not use different
model families, but the reviewer must start without implementation context and
must not be the author grading their own work. The reviewer does not edit the
material under review.
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
stands, the landing page and distribution are Claude's alone, and running them
through spec-writing and review buys nothing: the spec would restate what is
already unambiguous, and author and reviewer would be the same role. So no spec
file, no spec review, no code review, no walk through `S1`…`S8`.
**Infrastructure-only work uses an accelerated entry into the common flow.** It
is implemented immediately by any agent, without analysis, spec, spec review or
the statuses `S1`…`S6`. Once the branch is ready and pushed, apply
`S7-code-review`. From there the ordinary controller applies: green review rebases
and merges the checked material into `dev` and then sets `S8-merged`; findings or
a failed merge return the issue to `S6-in-progress`, and after correction it is
submitted to `S7-code-review` again.
The test for "infrastructure only" is mechanical: **not a single class A file** —
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
@@ -220,22 +229,28 @@ a loose reading would turn this into the route by which product changes skip
review.
What stays mandatory either way: an issue exists, both trailers are on every
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
written down in the code or the issue rather than kept in someone's head.
commit, proportionate local gates are green (normally `typecheck`, `test` and
`build`), and any non-obvious decision is written down in the code or the issue
rather than kept in someone's head. Infrastructure work skips specification, not
code review.
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
pipeline. Deterministic gates, model review and integration have independent
55/45/55-minute budgets (#551); typical runs finish well before those ceilings.
**Having applied one of those labels, wait for the result instead of ending the
session.** Reporting "handed over for review" stops a conveyor that could have kept
moving on its own. An agent has no clock — it exists only during its own turn — so
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
waiting means polling: every 90 seconds, at most 110 times. A single long sleep hits
the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue NN
[--sha <tip>]` (#496): it watches the label, the pipeline's own comments (conflict,
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
nothing. Watch the **label**, not the comment: the label is the state,
4 on timeout — the same 90 s × 110 without a model turn per tick. It writes
nothing. Pipeline comments older than the latest application of `S4`/`S7` are
the baseline, not an outcome of the new round; an outcome from the current round
which already exists when the waiter starts is still delivered immediately (#546).
Watch the **label**, not the comment: the label is the state,
the comment only explains it. Do not wait at all while `blocked` is set — the task
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
tell the owner: a failed run leaves the label where it was, forever.
@@ -253,6 +268,15 @@ What the new label means:
**After a review run the label always changes.** If it did not, the run itself
failed rather than the work — say so to the owner instead of polling on.
The repository also has a bounded queue reconciler (#555). It takes one S4/S7
snapshot every thirty minutes and exits. It may re-apply the same review label
only when the matching event was lost or its run ended with a transient
cancellation/timeout before a sealed result existed. It never applies verdicts or
merges. Running work, `blocked`, `review-4`, a foreign material/stage/attempt, a
guard failure, or an unintegrated sealed model result is left untouched and gets
at most one machine-keyed diagnostic. This is a safety net, not permission for an
agent to stop waiting for the result of the review it started.
**A failed pre-release gate does not send the issue back to review.** The
implementation loop runs only typecheck, unit and build; golden, browser smokes,
performance and the full HA harness run before a beta, which is after the code
@@ -305,10 +329,14 @@ does not meet.
## Specs
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
on which surface, at what moment; and what the person sees before and after, in one
sentence without implementation terms.
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
product ones: which persona meets this, on which surface, at what moment; and what
the person sees before and after, in one sentence without implementation terms.
Proof that a verdict was passed on a given text is the pipeline's job: it writes
the `sha256` of the normalised body into the review document's anchor block, and
an edit made after a green spec review reaches the code reviewer as a finding.
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
fact is the worst kind of defect: it passes review because it looks like a decision.
@@ -373,7 +401,8 @@ push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
units, build, bundle sync, no-new-any), the narrow TS/Python geometry parity
guard when its inputs changed, backend, hacs and hassfest. Screenshot
freshness in `check-docs` is likewise a warning on a plain push and an error on
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
without the `Release:` trailer, so a green Validate without the heavy jobs can
@@ -387,6 +416,18 @@ demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
locally it costs a minute. Precedent: on #89 a fixture error lived through a
whole review round that a local run would have caught immediately.
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
locally with `gh` available before pushing (without `gh` the hook cannot check the
issue status and stays silent). After `S7-code-review` do not push to the branch
until the verdict or the return arrives: a push on top of a running review cancels
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
with the diff mutants on the material itself and returns a red one to `S6` without
spending a review cycle. Mutants by diff no longer run on ordinary pushes — only
on the review candidate, the merge candidate, the beta candidate, PRs and the
nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56
Validate job-hours and were mostly cancelled by the next push.
The full smoke set, `golden` and `performance_smoke` still belong to the
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
@@ -409,7 +450,14 @@ simply the only one we have). The deliberate override is
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
manifest. Baselines are still accepted only via
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
index records the platform next to the Chromium build.
index records the platform next to the Chromium build. The one local
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
machine, compares decoded pixels with the committed frames and, only when every
frame is identical, refreshes the manifest fingerprints — frames that differ go
through the artefact as before. The displayed card version reaches the DOM
through `displayVersion()` (`src/card-version.ts`); the global
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
sets it.
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
@@ -442,21 +490,36 @@ assertions go red and part stay green, which reads as a logic defect.
success` run for the candidate's SHA, not "the last green one"; a new push cancels
an unfinished Validate for the same branch. Gate jobs, matching the actual
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
`frontend`, `smoke`, `golden`, `performance_smoke`, `geometry_parity`, `backend`.
The `changes` job
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
went red after the #113 merge.
went red after the #113 merge. A stable release additionally waits for Full
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
dispatched on the candidate SHA by `release.yml`, #514/#540); betas and the
development cycle never run E2E. Stable installable assets (`houseplan.zip`,
`houseplan-card.js` and their `SHA256SUMS`) reach the public stable release only
from `release.yml` after those gates; a release published by hand is turned back
into a draft first (#540). The prerelease publisher additionally ships a
candidate-bound `RELEASE-MEMBERSHIP.json` covered by the same passport (#547).
**"Verified" without a named command and its result is not evidence.**
## Environments
**Local Windows checkout** is the day-to-day environment: Node 22 and Python 3.14
as in CI (`npm run toolchain:check` compares the machine with the pins CI actually
uses — `.nvmrc` and `.python-version` are derived from the same sources, #496),
`gh` authenticated. `.venv-backend` does **not** exist there — it is
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
Playwright Chromium.
**Local Windows checkout** is the day-to-day environment: repository-pinned Node
and Python as in CI (`npm run toolchain:check` compares the machine with the pins
CI actually uses — `.nvmrc` and `.python-version` are derived from the same
sources, #496),
`gh` authenticated. On the owner's machine do not trust the ambient PATH:
`.\scripts\windows-toolchain.ps1 setup|check` owns a verified portable Node and
dedicated `.venv-ci`, and its `npm`/`node`/`python`/`playwright` actions are the
explicit pinned entrypoints (#557). It changes no persistent PATH and never
deletes a mismatched venv. In WSL, work from an ext4 clone and use
`bash scripts/wsl-setup.sh --verify` for the real HA subset plus one Linux visual
capture; exact-SHA Linux CI remains authoritative. `.venv-backend` does **not**
exist there — it is provisioned only by cloud agent startup scripts, which also
run `npm ci` and install Playwright Chromium.
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
@@ -499,4 +562,7 @@ the work itself and follows the ordinary rules, trailers included.
Issues are closed in a batch when a beta ships, not when implementation ends: that
way a bug found in the beta returns to the same task, and the beta announcement can
list what went in. Status labels are stripped as the issues close.
list what went in. The batch is immutable candidate membership proven by Git
trailers, never the mutable S8 queue at close time; authorship does not change
membership. Status labels are stripped as the issues close, and retries resume the
same manifest without duplicating the release comment (#547).
+205 -77
View File
@@ -1,9 +1,11 @@
# Процесс работы над House Plan
> **Статус документа: канон** (редакция 2026-08-13). Решения владельца, на
> **Статус документа: канон** (редакция 2026-08-13, ролевое уточнение
> 2026-09-13). Решения владельца, на
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
> имена английские · лёгкий трек **включён** · автор и ревьюер — разные модели ·
> инфраструктурные задачи идут **вне** флоу.
> имена английские · лёгкий трек **включён** · автор и ревьюер — независимые
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
> входят в общий флоу сразу на `S7-code-review`.
>
> **Область действия:** обязателен для владельца и для любого агента. Читается
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
@@ -12,10 +14,15 @@
>
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. При расхождении этого документа с
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
> заводится issue с меткой `process`.
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
> процесса в репозитории; `AGENTS.md` — его короткое обязательное резюме, а
> внешний `CODEX-RUNBOOK.md` — только маршрутизатор к канону и историческим
> инструкциям. Текущие версии, состояние конкретных issue, runtime pins и списки
> jobs не копируются в производную прозу: они читаются из своих исполняемых
> источников.
> При расхождении этого документа с `.github/workflows/*.yml` и `scripts/*`
> побеждает **фактическая автоматизация**: она исполняется, а описание — нет.
> Расхождение при этом не игнорируется, а заводится issue с меткой `process`.
>
> При расхождении процесса и привычки побеждает процесс.
@@ -44,13 +51,20 @@
лежит внутри `custom_components/houseplan/frontend/`, и без этого правила он
считался бы продуктовым исходником.
**Инфраструктурная задача идёт вне флоу** (решение владельца 2026-08-13,
issue #118). Признак механический: **ни одного файла класса A**. Такая задача
делается без ТЗ, ревью ТЗ, код-ревью и без прохода по статусам — флоу построен
для изменений, у которых есть персона и видимое поведение, а в инфраструктуре ТЗ
пересказывало бы очевидное, и автор с ревьюером оказались бы одной ролью.
Проверкой служат гейты и CI. Обязательным остаётся issue, трейлеры и зелёные
`typecheck`, `test`, `build`.
**Инфраструктурная задача использует ускоренный вход в общий флоу** (решение
владельца 2026-09-13, issue #562). Признак механический: **ни одного файла класса
A**. Любой агент может сразу реализовать её по issue и в ветке
`issue/<NN>-<slug>`, без аналитики, ТЗ, ревью ТЗ и статусов `S1`…`S6`. Когда
материал готов, локальные гейты зелёные и ветка запушена, исполнитель ставит
`S7-code-review`. Дальше действует тот же контроллер, что для продуктового кода:
зелёное ревью сливает проверенный материал в `dev` и ставит `S8-merged`, а
замечания или неудавшееся слияние возвращают задачу в `S6-in-progress`; после
исправлений она снова идёт в `S7-code-review`.
Отсутствие ТЗ не означает отсутствие проверки. Для инфраструктуры обязательны
issue, терминальные трейлеры, соразмерные изменению зелёные гейты, хендофф с
доказательствами и независимое код-ревью. Модель или имя агента процессом не
предписываются.
Задача, задевающая класс A хотя бы одним файлом, инфраструктурной **не
является** и идёт полным флоу. «В основном инфраструктурная» не бывает: иначе
@@ -61,7 +75,8 @@ issue #118). Признак механический: **ни одного фай
## 2. Жизненный цикл
Восемь рабочих статусов и два служебных. Фазы тестирования в цикле сознательно
Восемь рабочих статусов и два служебных. Полный маршрут ниже относится к
продуктовым задачам. Фазы тестирования в цикле сознательно
**нет**: найденные позже дефекты заводятся отдельными issue и проходят цикл
заново. Issue закрывается после выпуска беты.
@@ -72,6 +87,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
инфраструктурный трек (§1): без S → S7-code-review ⟲ S6-in-progress → S8-merged
```
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
@@ -122,9 +138,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
### 2.3 ТЗ в работе — написание ТЗ
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10,
#517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив
ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме.
Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей
документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после
зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной.
- **Выход:** полная первая редакция по §7.
### 2.4 ТЗ на ревью
@@ -155,8 +174,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
- влияние на производительность и бюджеты названо (или явно «нет»);
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
документация, golden/скриншоты, performance/security);
- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты,
performance/security — либо явное «нет»;
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
- открытых продуктовых вопросов нет; риски перечислены.
@@ -175,6 +194,16 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
тестирования, а не отсутствие тестов.
- **Приёмка проверяет результат для человека, а не строки реализации.** Для
изменённой поверхности автор выбирает обычный сценарий и самый рискованный
применимый соседний случай; у каждого должен быть наблюдаемый oracle — что
пользователь видит, может сделать или что система отказывается делать. При
выборе случаев коротко пройти шесть классов риска: async (порядок, отмена,
устаревший ответ); данные и права (пусто, нет связи, несколько источников,
отказ); геометрия (границы, стыки, трансформации); визуал (промежуточный кадр,
тема, zoom/DPR); объём данных и performance; host/input (HA, кэш, mouse/touch/
keyboard). Неприменимое так и отмечается; проверка имени метода или строки
исходника пользовательским oracle не считается.
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
Попутных правок «раз уж я здесь» не бывает.
@@ -189,10 +218,13 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **Артефакт:** `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md` в действующем
формате: скоуп, как проверялось (таблица гейтов с результатами), находки
High/Medium/Low с воспроизведением, что проверено и корректно, чего не проверял.
- **Ревьюер отвечает за AC.** Раз ручного тестирования в цикле нет, именно ревью
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
коду с явной записью «проверено чтением, не исполнением».
- **Ревьюер отвечает за полноту доказательств AC, а не заменяет их исполнение.**
Каждый AC либо доказан автотестом — и ревьюер убедился, что **тест умеет
падать**, — либо разобран по коду с явной записью «проверено чтением, не
исполнением». Чтение кода выявляет риски, но не доказывает наблюдаемый
пользовательский результат; если соразмерный исполнимый oracle возможен, его
отсутствие — находка. Ревьюер отдельно сверяет применимые классы риска из
§2.6 и не выдаёт запуск гейта за проверку сценария, которого в гейте нет.
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
@@ -428,19 +460,20 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
**Что упрощается:**
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
доказательством · откат. Файл в `docs/specs/` не создаётся;
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
(в теле issue, как и на полном треке с 2026-09-10);
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
- лимит ревью ТЗ — 2 цикла.
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается
никогда — именно оно в этом процессе заменяет тестирование. Единственное
исключение — починка упавшего предрелизного гейта, §11.4.
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается:
оно проверяет скоуп, риски и качество доказательств, но не заменяет исполнение
тестов. Единственное исключение из повторного ревью — починка упавшего
предрелизного гейта, §11.4.
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
полное ТЗ в теле issue по §7.1. Это не провал, это ранняя диагностика.
### 5.1 Короткий трек (метка `trivial`)
@@ -488,7 +521,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
| Роль | Делает | Не имеет права |
|---|---|---|
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
@@ -498,19 +531,18 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
**Правило разделения:** ревьюер работает состязательно. Ему передаётся тег или
диапазон коммитов и ТЗ — не рассказ автора о том, как всё хорошо.
**Роли закреплены за исполнителями** (решение владельца 2026-08-12):
**Роли не закреплены за моделями или именами агентов** (решение владельца
2026-09-13, issue #562). Codex, Claude или любой другой доступный агент может быть
аналитиком, автором ТЗ, разработчиком, автором инфраструктурной задачи или
релиз-инженером по прямой команде владельца.
| Исполнитель | Роли |
|---|---|
| **Codex** | аналитик, автор ТЗ, разработчик, релиз-инженер по команде владельца |
| **Claude** | ревьюер ТЗ, ревьюер кода, вся инфраструктура и дистрибуция |
| **Владелец** | приоритет, скоуп, арбитраж, закрытие issue, команда на выпуск |
Разделение относится к артефакту: **автор и ревьюер — разные агенты/сессии**.
Модель может совпадать, но ревьюер начинает без контекста реализации и не ставит
вердикт собственной работе. Ревью ТЗ и код-ревью также идут в независимых
сессиях: ревьюер кода не должен приходить с контекстом обсуждения ТЗ.
Автор и ревьюер — **разные модели**, и это сильнее требования «другая сессия»:
одна модель, читая свой же артефакт заново, повторяет свои же слепые пятна.
Ревью ТЗ и код-ревью держатся в **разных сессиях** Claude: ревьюер кода не должен
приходить с контекстом того, как обсуждали ТЗ.
Владелец сохраняет исключительные решения о приоритете, ценности, продуктовом
скоупе, отклонении, арбитраже, закрытии issue и команде на выпуск.
---
@@ -520,7 +552,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
```
issue #NN
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
↔ ветка issue/NN-slug
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
@@ -593,10 +625,9 @@ issue #NN
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
таблицу «issue ↔ ТЗ».
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
вместе с самой таблицей. Статус задачи — только метка `S*`.
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
@@ -621,6 +652,9 @@ npm run golden:verify # если менялся визуал
node scripts/check-docs.mjs # если менялся src/**
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд
npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
&& python tests_backend/junction_parity.py --build-dir=test-build/junction-parity
# если менялось одно из зеркал junction limits
```
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
@@ -653,6 +687,13 @@ python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:c
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
декодирует оба набора в Chromium и при нуле отличающихся пикселей во всех
кадрах обновляет только отпечаток исходников в `screenshots.json`; байты
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
@@ -694,7 +735,11 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
Performance зелёные на точном SHA; статусов issue не касается.
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на SHA кандидата и ждёт
его зелёного (#514, #540); установочные ассеты публикуются только после всех
гейтов и только этим workflow — релиз, опубликованный руками, возвращается в
черновик до их прохождения (#540); статусов issue не касается.
---
@@ -728,18 +773,22 @@ Performance зелёные на точном SHA; статусов issue не к
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
Инварианты: **ровно одна `S*`-метка** на открытом issue; закрытый issue статусных
меток не несёт; `blocked` не заменяет статус, а дополняет его.
Инварианты: продуктовая задача в процессе несёт **ровно одну `S*`-метку**.
Инфраструктурная задача может не иметь `S*` во время первоначальной реализации;
с первого `S7-code-review` на неё действует тот же инвариант ровно одной метки.
Закрытый issue статусных меток не несёт; `blocked` не заменяет статус, а дополняет
его.
**Чужой issue берётся в работу так же, как свой — после явного решения
владельца** (решение владельца 2026-08-13, уточнено в тот же день). Репозиторий
публичный, отчёты заводят и посторонние; проверка стоит **на входе**, а не на
каждом шаге.
Входом служит присвоение первой статусной метки: пока меток нет, issue вне
процесса и инварианты на него не распространяются. Как только метка стоит, задача
в работе, и **кто её завёл, дальше не имеет значения** — статусы, ревью и лимиты
работают одинаково.
Для продуктовой задачи входом служит присвоение первой статусной метки. Для
инфраструктурной — явное назначение владельцем; до готовности к первому
код-ревью она может оставаться без `S*`. Как только продуктовая задача вошла в
полный маршрут либо инфраструктурная получила `S7-code-review`, **кто её завёл,
дальше не имеет значения** — статусы, ревью и лимиты работают одинаково.
Присвоение метки и есть то самое явное решение, причём проверенное платформой:
метки может ставить только тот, у кого есть право записи в репозиторий. Прежняя
@@ -811,9 +860,11 @@ Performance зелёные на точном SHA; статусов issue не к
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
2. имя ветки `issue/NN-slug` соответствует трейлерам;
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue —
либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет,
и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ).
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
заморожен (#517);
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
`Baseline-Reviewed: <ссылка на прогон CI>`;
@@ -849,10 +900,17 @@ post-beta коммит остаётся в проверке и по закрыт
Не реализовано и остаётся долгом:
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
зелёный вердикт код-ревью;
10. закрытие issue и снятие статусных меток при публикации беты делаются руками —
`node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`.
Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки».
зелёный вердикт код-ревью.
Закрытие вошедших issue автоматизировано (#120, #547). При старте беты текущая
очередь S8 служит только списком кандидатов: `RELEASE-MEMBERSHIP.json` оставляет
из неё лишь номера с доказанным `Issue: #NN` в Git-диапазоне зафиксированного
SHA. Manifest публикуется и входит в `SHA256SUMS`; свежая очередь S8 после
публикации не перечитывается. Общий для workflow и локальной команды bookkeeping
идемпотентно добавляет один маркированный release-комментарий, снимает все
статусные метки и закрывает issue. Поэтому повтор после сбоя продолжает manifest,
даже если метка уже снята или release уже public; автор issue на membership не
влияет.
### 10.3 Страховка и разбор
@@ -879,11 +937,13 @@ S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
```
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
вердикту, а не модель.**
Текущая техническая реализация независимого ревьюера —
`anthropics/claude-code-action`; это деталь автоматизации, а не закрепление роли
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`, этот
документ и тело issue, публикует разбор комментарием, заводит issue на
Medium-находки вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки
задачи и возвращает вердикт структурированным JSON. **Метку переставляет отдельная
детерминированная стадия по вердикту, а не модель.**
Четыре вещи, без которых конвейер молча не работает:
@@ -897,14 +957,56 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты,
в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты).
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
job-минут Validate и в основном отменялись следующим пушем.
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
съедает время модели, а ожидание кандидата после зелёного вердикта не обрывает
готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
этап, раунд, branch, SHA/tree материала, якоря ТЗ и результат Validate. Получатель
сверяет полный набор файлов, SHA-256 и все поля с outputs предыдущей стадии;
неполный, чужой или устаревший результат fail-closed не публикуется и не разрешает
merge. Timeout/cancel/failure называет конкретную стадию и оставляет метку на месте;
если модель не запускалась, цикл ревью не расходуется. Длительности всех трёх
стадий печатаются отдельной таблицей в summary прогона.
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
диффу, который читает ханки `git diff --unified=0`. Сторона гарда осталась
файловой: у гарда якоря нет. Неоднозначный якорь и непрочитанные ханки дают
прежний широкий ответ — незнание не доказательство. Приближение того же класса,
что и сам отбор по диффу; нижняя граница — ночной полный гейт (#513). Шард
считает свой план до установки окружения и при пустом плане не платит за
npm ci, Python и Chromium, оставаясь исполненной job: доказательство гейта
требует успешной job, а не пропущенной.
**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs
--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит);
после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш
поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации
материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после
каждого фикса CI: красный Validate конвейер вернёт сам.
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
секунд, не более 110 попыток (запас на три независимых бюджета #551) —
`node scripts/wait-verdict.mjs --issue NN` делает его
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
не на комментарий: метка и есть состояние. При `blocked` не ждать — задача ждёт
владельца.
не на комментарий: метка и есть состояние. Комментарии конвейера до последнего
применения `S4`/`S7` считаются историческим baseline, а уже опубликованный исход
текущего раунда доставляется сразу при первом опросе (#546). При `blocked` не
ждать — задача ждёт владельца.
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
@@ -945,8 +1047,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, ожидание
зелёного Validate **на этом SHA** и только затем push в `dev` с lease на ту
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
@@ -966,6 +1069,25 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
сообщать владельцу, а не продолжать опрос.
**Очередь S4/S7 сверяется отдельным bounded controller (#555).** Workflow
`process-reconcile.yml` раз в полчаса делает один снимок открытых задач и
завершается — активного polling одинакового состояния и вызова модели на каждый
тик нет. Он сопоставляет последнюю постановку `S4`/`S7` с run по номеру задачи и
этапу; если стадия успела подготовить материал, дополнительно проверяет
запечатанные run/attempt, SHA/tree, список блобов ТЗ и номер раунда. Текущий
`blocked`, `review-4` или снятая review-метка всегда сильнее старого события.
Автоматически и не более одного раза повторно применяется только сама review-метка после доказанно
потерянного события либо transient-исхода до получения запечатанного результата
(`cancelled`, `timed_out`, `stale`, `startup_failure`, `skipped`). Это не применяет
вердикт и не расходует цикл: обычный конвейер заново читает актуальные labels и
материал. Здоровый running run не трогается. Failure guard, неизвестная связь,
чужой material/stage/attempt, success без смены метки и сбой после появления
`review-result`, а также потеря повторного события получают один дедуплицированный диагностический комментарий и
эскалацию человеку — второй вызов модели или S8 по догадке запрещены. Перед любой
записью controller перечитывает состояние; итог каждого прохода публикуется как
`houseplan-process-reconcile/v1` artifact.
**Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard
читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди,
пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом
@@ -1083,9 +1205,9 @@ Golden, браузерные смоки, performance и полный HA-харн
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
одна `S*`-метка, инварианты чистые.
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
код, которого уже нет.
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
@@ -1119,9 +1241,15 @@ Golden, браузерные смоки, performance и полный HA-харн
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
опросом и продолжает по тому, чем она стала.
Инфраструктурная задача (ни одного файла класса A) делается сразу любым агентом:
без `S*` → `S7-code-review` ↔ `S6-in-progress` → `S8-merged`. ТЗ и ревью ТЗ нет,
код-ревью обязательно.
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review`.
Deterministic prerequisites, модель и интеграция имеют отдельные пределы 55/45/55
минут; обычно стадии заканчиваются существенно раньше. Поставив такую метку, автор
не заканчивает работу, а ждёт смены метки опросом и продолжает по тому, чем она
стала.
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
+1 -1
View File
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
ASSETS_DIR = "houseplan/assets"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.73.0"
VERSION = "1.76.0-beta.3"
DECOR_ASSETS_API_VERSION = 1
SUMMARY_PANEL_API_VERSION = 1
@@ -1,312 +1,295 @@
{
"schema": 1,
"fingerprint": "95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74",
"fingerprint": "0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e",
"entry": "houseplan-card.js",
"panelEntry": "houseplan-panel.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/houseplan-card-BCGpvbDT.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 298891,
"initialViewGzipBytes": 290379,
"initialPanelFiles": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-card.js",
"houseplan-assets/houseplan-card-BCGpvbDT.js",
"houseplan-panel.js"
],
"initialPanelGzipBytes": 301193,
"initialPanelGzipBytes": 291901,
"initialPanelOnlyFiles": [
"houseplan-panel.js"
],
"initialPanelOnlyGzipBytes": 2302,
"initialPanelOnlyGzipBytes": 2325,
"lazyFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/fr-T10CV6-a.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js",
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"houseplan-assets/pdf-export-DpHlViMV.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/backdrop-pick-Caayxvt2.js",
"houseplan-assets/de-JVEEGD9u.js",
"houseplan-assets/editor-C5Zq-3aA.js",
"houseplan-assets/fr-B-ete5Dh.js",
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
"houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js",
"houseplan-assets/hp-zigbee-topology-overlay-D3IWAxB8.js",
"houseplan-assets/iso-scene-render-ceJKJKhC.js",
"houseplan-assets/live-interaction-runtime-CxW-k-p_.js",
"houseplan-assets/pdf-export-BAtsOfAV.js",
"houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js",
"houseplan-assets/zigbee-topology-DlU7AXvw.js",
"houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js"
],
"lazyGzipBytes": 417329,
"lazyGzipBytes": 418510,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/backdrop-pick-Caayxvt2.js",
"houseplan-assets/editor-C5Zq-3aA.js",
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
"houseplan-assets/zigbee-topology-DlU7AXvw.js"
],
"lazyEditorGzipBytes": 205633,
"lazyEditorGzipBytes": 204784,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js"
"houseplan-assets/backdrop-pick-Caayxvt2.js",
"houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js"
],
"lazyOnboardingGzipBytes": 14148,
"lazyOnboardingGzipBytes": 13914,
"lazyLocaleFiles": [
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/fr-T10CV6-a.js"
"houseplan-assets/de-JVEEGD9u.js",
"houseplan-assets/fr-B-ete5Dh.js"
],
"lazyLocaleGzipBytes": 54843,
"lazyLocaleGzipBytes": 54845,
"lazyIsometricFiles": [
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js"
"houseplan-assets/iso-scene-render-ceJKJKhC.js"
],
"lazyIsometricGzipBytes": 13452,
"lazyIsometricGzipBytes": 13232,
"lazyFurnitureArtFiles": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js"
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js"
],
"lazyFurnitureArtGzipBytes": 10253,
"lazyFurnitureArtGzipBytes": 10252,
"lazyPdfFiles": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/pdf-export-DpHlViMV.js"
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"houseplan-assets/pdf-export-BAtsOfAV.js"
],
"lazyPdfGzipBytes": 120367,
"lazyPdfGzipBytes": 120125,
"files": [
{
"path": "houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"sha256": "a4abe8807d8c404a7ce9f65b0a72ca3d8f8d4d5bf38fa8220915818f9238c0db",
"rawBytes": 21134,
"gzipBytes": 7144,
"path": "houseplan-assets/backdrop-pick-Caayxvt2.js",
"sha256": "3a8a8a4b522eaae770f7b0cdd5612aaa542fd81e2fef1adcb58d21d9b093b360",
"rawBytes": 19956,
"gzipBytes": 6881,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-IBWzSVOj.js",
"sha256": "cf031fb380b4b056c116e14a8cb26729ae4d0b275991b07989917f884f8841ff",
"rawBytes": 91441,
"gzipBytes": 27705,
"path": "houseplan-assets/de-JVEEGD9u.js",
"sha256": "f300635f221656a36e591183395cc38c77c093e2347ce98ac0b0f324f7ec8306",
"rawBytes": 91437,
"gzipBytes": 27709,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-DKd1NGbT.js",
"sha256": "e62e388db3b39eaa5f0e8f9b9676b7a6e30c453f84d22d530740f9be093512d1",
"rawBytes": 3837,
"gzipBytes": 1585,
"path": "houseplan-assets/editor-C5Zq-3aA.js",
"sha256": "4878d5efac2222fdd3fda5e695588a8d5cfe463027397b2fc8499bd6e649c6a7",
"rawBytes": 3831,
"gzipBytes": 1583,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-T10CV6-a.js",
"sha256": "b602e1137a8395f963be744ed43d2054e472093502f687e40e6bd3e7a25a8794",
"rawBytes": 93719,
"gzipBytes": 27138,
"path": "houseplan-assets/fr-B-ete5Dh.js",
"sha256": "c4d9a2f7bf0fca73861727752c5cd9b41e8cda469685e32eb34dedc8ec0f790b",
"rawBytes": 93701,
"gzipBytes": 27136,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"sha256": "1e26cad8d04b459f4b1140e705a02a1d380b8c49a84f92b8ea2ce3e684544800",
"path": "houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"sha256": "69da2271017aa401f35e87387fb06d384617ee78f0d534bbd28792f5c3cb33e8",
"rawBytes": 33655,
"gzipBytes": 10253,
"gzipBytes": 10252,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/guard-7S6iRB0N.js",
"sha256": "67f95421a21516a66157d8fdc0129c2d1280cee31a3621a81039c88835d1c0f8",
"rawBytes": 518,
"gzipBytes": 368,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-Bj6mVEeM.js",
"sha256": "dea85ab6dfeebcb263bd672270041e34473d40b9db24e69259e55d42e5ff44d4",
"rawBytes": 1076387,
"gzipBytes": 298088,
"path": "houseplan-assets/houseplan-card-BCGpvbDT.js",
"sha256": "83f27e261a8a7a32463b67762c859e431d1468a32af7249627ec326f16696772",
"rawBytes": 1031731,
"gzipBytes": 289576,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-IBWzSVOj.js",
"houseplan-assets/editor-DKd1NGbT.js",
"houseplan-assets/fr-T10CV6-a.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"houseplan-assets/iso-scene-render-miMf0jvt.js",
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"houseplan-assets/pdf-export-DpHlViMV.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
"houseplan-assets/de-JVEEGD9u.js",
"houseplan-assets/editor-C5Zq-3aA.js",
"houseplan-assets/fr-B-ete5Dh.js",
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
"houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js",
"houseplan-assets/hp-zigbee-topology-overlay-D3IWAxB8.js",
"houseplan-assets/iso-scene-render-ceJKJKhC.js",
"houseplan-assets/live-interaction-runtime-CxW-k-p_.js",
"houseplan-assets/pdf-export-BAtsOfAV.js",
"houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
"sha256": "bc5163222924f68e6279b412b16a53f28a76b5b4d80d4a03e7fe28c4cce77a39",
"rawBytes": 671972,
"gzipBytes": 178859,
"path": "houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
"sha256": "7a0814ddc88324ad5dcb87f43852bd1f92ed90e4bda658cab3a8cdfee3209c18",
"rawBytes": 669271,
"gzipBytes": 178643,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/backdrop-pick-Caayxvt2.js",
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"houseplan-assets/houseplan-card-BCGpvbDT.js",
"houseplan-assets/zigbee-topology-DlU7AXvw.js"
],
"dynamicImports": [
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js"
]
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
"sha256": "31ea22267928606938b34c47f1ac8ecc2041048bc8c045a411d7108d353de849",
"rawBytes": 28237,
"gzipBytes": 7004,
"path": "houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js",
"sha256": "6484dea33a514dff985b752d3f3ff87086a87ab2e46e79d29773d382d9fe4695",
"rawBytes": 28287,
"gzipBytes": 7033,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/backdrop-pick-Caayxvt2.js",
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
"sha256": "0e18deb183cb473c2a96fb689c91c68a3729b8ad56fb8907b01c30810585dbec",
"rawBytes": 11034,
"gzipBytes": 3731,
"path": "houseplan-assets/hp-zigbee-topology-overlay-D3IWAxB8.js",
"sha256": "1dd14e9ee7bb06dd0ca154b471e16f410abc14c0beb5e310266f71514e770f9b",
"rawBytes": 10756,
"gzipBytes": 3685,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js",
"houseplan-assets/zigbee-topology-DlU7AXvw.js",
"houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/iso-scene-render-miMf0jvt.js",
"sha256": "00fd101b4e5469f6d289b77eab3056bedf98082520af9139a1afff5823fcb025",
"rawBytes": 38562,
"gzipBytes": 13084,
"path": "houseplan-assets/iso-scene-render-ceJKJKhC.js",
"sha256": "e7032d13f4479fa28d8b378c900a339577e74f5c377da9b080697e20e02f5903",
"rawBytes": 39206,
"gzipBytes": 13232,
"isEntry": false,
"imports": [
"houseplan-assets/guard-7S6iRB0N.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
"sha256": "631b277aae71d9977fabb3f330c4a9cdf38e47ec51259584540b747d9c9444df",
"rawBytes": 5196,
"gzipBytes": 2124,
"path": "houseplan-assets/live-interaction-runtime-CxW-k-p_.js",
"sha256": "3b0f3ae94e957cb662f63abeb5b7fb95012da6654a0ad5a857cf7b1b6a4cb6ac",
"rawBytes": 8216,
"gzipBytes": 3209,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/pdf-export-DpHlViMV.js",
"sha256": "48b07b9ecfbbf2c1a8de53edce8bc32b1217e2c2f20eb5ceb24af75999870d8a",
"rawBytes": 237254,
"gzipBytes": 110114,
"path": "houseplan-assets/pdf-export-BAtsOfAV.js",
"sha256": "2e57254cc4a637a6ed689ee1de9af9f4dc60595489683b238ead4026f7e4d60b",
"rawBytes": 236244,
"gzipBytes": 109873,
"isEntry": false,
"imports": [
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-editor-DOpNDtK_.js",
"sha256": "4ed28cc81b7f0ccac46e0fb6ac37856ddbdf7813040585447089bf4a0a61805f",
"rawBytes": 14811,
"gzipBytes": 3390,
"path": "houseplan-assets/summary-panel-editor-BAGJ8QwD.js",
"sha256": "72d3ad8f8d61228dc255eed21ff157ad9c96ee8a35bd7b7b999f4090adb1925c",
"rawBytes": 14824,
"gzipBytes": 3401,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js",
"houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-metrics-DXF83oAy.js",
"sha256": "51953d004b76561a842f67b9455985cceac2663c52499d1e1acc3a3cdfdaded9",
"rawBytes": 2195,
"gzipBytes": 1121,
"path": "houseplan-assets/summary-panel-metrics-CXnAEe7_.js",
"sha256": "c73f5de8717a8a7d5e7e0d0861fb9d007876b50c4c62f97b3c7f1739cd5bb38f",
"rawBytes": 2610,
"gzipBytes": 1304,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
"sha256": "7598bcc5d26d8d2f9a15cc154c32a0b836ba0ac53b7b5f9b65f3c2c2bc0287d1",
"rawBytes": 69530,
"gzipBytes": 19034,
"path": "houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js",
"sha256": "8609218f07a5ec5399a2ba61707a2854dfaa65e80b11dc628ddb3ece70d77f72",
"rawBytes": 73366,
"gzipBytes": 20089,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": [
"houseplan-assets/summary-panel-editor-DOpNDtK_.js",
"houseplan-assets/summary-panel-metrics-DXF83oAy.js"
"houseplan-assets/summary-panel-editor-BAGJ8QwD.js",
"houseplan-assets/summary-panel-metrics-CXnAEe7_.js"
]
},
{
"path": "houseplan-assets/zigbee-topology-DOkBU6gZ.js",
"sha256": "0176688590ae76a8766ee735de892e37f29211a042c27f5a2992eda0cc8ef8d9",
"path": "houseplan-assets/zigbee-topology-DlU7AXvw.js",
"sha256": "f7f21945cb650b57fa6469a7205aa687415b208f9649e2e8bae9dd27aa6cf4e6",
"rawBytes": 18961,
"gzipBytes": 7424,
"gzipBytes": 7425,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js",
"sha256": "6b43a567d3686249519d43ef6f04bb9596529d99d04f745944c0e561c0e3034e",
"path": "houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js",
"sha256": "7ae2a9b1aac6ea4ee128341cce087684b546efd2788b97fa6941138ddb702f9f",
"rawBytes": 3941,
"gzipBytes": 1762,
"gzipBytes": 1760,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js",
"houseplan-assets/zigbee-topology-DlU7AXvw.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b8095427f52e7413e84d3202953d15765fbd43d4536226beff01ba7ff692ae2e",
"sha256": "642174a115a5be423b55f552e351f4445e58f744c22c4eb1378fc376ef16d65b",
"rawBytes": 1205,
"gzipBytes": 803,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
},
{
"path": "houseplan-panel.js",
"sha256": "f0a1709a58213029c08e73da2ed81b217827c8f5cdbf96e082385cfb608eabcc",
"rawBytes": 6124,
"gzipBytes": 2302,
"sha256": "8383bd59c693f7f9cc4b2aee4886b6a9fb90a715d91630e6473ce3aeae09216b",
"rawBytes": 6165,
"gzipBytes": 2325,
"isEntry": true,
"imports": [
"houseplan-card.js"
"houseplan-assets/houseplan-card-BCGpvbDT.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{q as e,l as o,v as t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,E as r,c}from"./houseplan-card-BCGpvbDT.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
@@ -1,14 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dV as r,b as c}from"./houseplan-card-Bj6mVEeM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
.computeLabel=${e=>h[e.name]||e.name}
@value-changed=${this._valueChanged}
></ha-form>`;return c`
${d(f.slice(0,3))}
${_?c`<div class="default-floor-error" role="alert"
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
${t(s,"editor.default_floor_missing",{id:_})}
</div>`:a}
${d(f.slice(3))}
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{dT as r,dU as e,dV as t}from"./houseplan-card-Bj6mVEeM.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{b as a,A as e}from"./houseplan-card-Bj6mVEeM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-2X-IAkWu.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{c as a,A as e}from"./houseplan-card-BCGpvbDT.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-BcKBuVQ7.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
<input id="summary-local-show" data-summary-local-show type="checkbox"
@@ -6,7 +6,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c46
?disabled=${d.busy} aria-label=${b("summary.show_local")}
aria-describedby="summary-local-show-hint"
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
.title=${b("summary.settings")} wide
.title=${b("summary.settings")} wide flex-content
dismiss-on-scrim aria-busy=${String(d.busy)}
@hp-close=${k}>
<div class="body summary-editor" @click=${()=>m.closeSource()}>
@@ -0,0 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{e9 as e,P as t,a5 as n,M as i,aq as r,N as o,br as a,bq as s,a4 as c,a3 as l,aA as u,ea as d,eb as f,bt as m,ec as g}from"./houseplan-card-BCGpvbDT.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const r=String(i.binding||"").indexOf(":");if(r<1)continue;const o=i.binding.slice(0,r),a=i.binding.slice(r+1),s="device"===o?e.registry.devices?.[a]:null,c="entity"===o?e.registry.entities?.[a]:null,l="device"===o?a:c?.device_id,u="device"===o?s?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===o&&n.liveEntities.has(a))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,r,o);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,r,o)}}function*h(c,l,u=_){try{let d=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=u(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const s=t(e);if(!s)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,r,o,g.roomGeom,g.multiWallNodes)||s;v=y(v,a(c,m.physicalBodies)),yield}const _=m.cellCm/r;d+=s(v)*_*_/1e4}return d}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let r=i.next();for(;!r.done;)r=i.next();return r.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=d(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
@@ -1 +0,0 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{az as e,ea as t,eb as n,bs as i,ec as r,P as a,a5 as s,M as o,aq as c,N as l,bq as d,bp as u,ed as f}from"./houseplan-card-Bj6mVEeM.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{n as t,b as n}from"./zigbee-topology-DOkBU6gZ.js";import{F as e}from"./houseplan-card-Bj6mVEeM.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{n as t,b as n}from"./zigbee-topology-DlU7AXvw.js";import{F as e}from"./houseplan-card-BCGpvbDT.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";try{await import("./houseplan-assets/houseplan-card-Bj6mVEeM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";try{await import("./houseplan-assets/houseplan-card-BCGpvbDT.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
File diff suppressed because one or more lines are too long
+1
View File
@@ -451,6 +451,7 @@ class HouseplanUploadView(HomeAssistantView):
partial(
check_quota, files_root, tmp_path.stat().st_size,
MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,
additional_disk_bytes=0,
),
)
except QuotaError as err:
+1 -1
View File
@@ -20,5 +20,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.73.0"
"version": "1.76.0-beta.3"
}
+15 -3
View File
@@ -222,9 +222,20 @@ def dir_usage(path: Path, *, exclude: Path | None = None) -> tuple[int, int]:
def check_quota(
path: Path, incoming: int, max_bytes: int, max_files: int, *, exclude: Path | None = None,
path: Path,
incoming: int,
max_bytes: int,
max_files: int,
*,
exclude: Path | None = None,
additional_disk_bytes: int | None = None,
) -> None:
"""Raise QuotaError unless `incoming` more bytes fit (`exclude`: see dir_usage).
"""Raise QuotaError unless `incoming` fits the store and disk.
`exclude` is omitted from current store usage but still charged as
`incoming`. `additional_disk_bytes` is the part not physically written yet;
by default all incoming bytes still need disk space. A staged file already
under `path` passes zero because promotion only renames it (#554).
Deliberately not an age rule. Files are never removed for getting old — that
cost real plans twice — so the limit sits where a decision is being made
@@ -245,7 +256,8 @@ def check_quota(
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
except OSError:
return
if free - incoming < MIN_FREE_BYTES:
disk_incoming = incoming if additional_disk_bytes is None else additional_disk_bytes
if free - disk_incoming < MIN_FREE_BYTES:
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
@@ -20,6 +20,17 @@ RADAR_PROFILES = frozenset({
"esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1",
"zones_v1", "presence_v1",
})
_COMMON_SOURCE_ROLES = (
"occupancy_entity", "count_entity", "availability_entity",
)
_PROFILE_SOURCE_ROLES: dict[str, tuple[str, tuple[str, ...]]] = {
"esphome_ld2450_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
"cartesian_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
"polar_v1": ("slots", ("distance_entity", "angle_entity", "presence_entity")),
"range_v1": ("ranges", ("entity_id", "presence_entity")),
"zones_v1": ("zones", ("entity_id",)),
"presence_v1": ("", ()),
}
LENGTH_UNITS = frozenset({"mm", "cm", "m", "in", "ft"})
ANGLE_UNITS = frozenset({"degrees", "radians"})
ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
@@ -90,18 +101,23 @@ def _point(value: Any, name: str) -> tuple[float, float]:
)
def _source_ids(sources: dict[str, Any]) -> set[str]:
def _source_ids(profile: Any, sources: dict[str, Any]) -> set[str]:
profile_roles = _PROFILE_SOURCE_ROLES.get(profile)
if profile_roles is None:
return set()
out: set[str] = set()
for key in ("occupancy_entity", "count_entity", "availability_entity"):
for key in _COMMON_SOURCE_ROLES:
value = sources.get(key)
if isinstance(value, str):
out.add(value)
for group in ("slots", "ranges", "zones"):
group, roles = profile_roles
if group:
for item in sources.get(group) or []:
if not isinstance(item, dict):
continue
for key, value in item.items():
if key.endswith("_entity") and isinstance(value, str):
for key in roles:
value = item.get(key)
if isinstance(value, str):
out.add(value)
return out
@@ -111,7 +127,8 @@ def radar_source_entity_ids(radar: Any) -> set[str]:
if not isinstance(radar, dict):
return set()
sources = radar.get("sources")
out = _source_ids(sources) if isinstance(sources, dict) else set()
out = _source_ids(radar.get("profile"), sources) \
if isinstance(sources, dict) else set()
return out
@@ -154,7 +171,7 @@ def _validate_verified_adapter(
if profile != "esphome_ld2450_v1" or registry is None:
return
entities = registry.get("entities") or {}
source_ids = _source_ids(sources)
source_ids = _source_ids(profile, sources)
rows = [entities.get(entity_id) for entity_id in source_ids]
device_ids = {
str(row.get("device_id"))
+19 -2
View File
@@ -180,7 +180,11 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
except (TypeError, ValueError):
return # CONFIG_SCHEMA owns malformed values.
previous = previous or {}
if old_model >= 10 and new_model < 10 and any(
stored_drafts = any(
"room_drafts" in space for space in previous.get("spaces") or []
if isinstance(space, dict)
)
if old_model >= 10 and not stored_drafts and any(
"room_drafts" in space for space in config.get("spaces") or []
if isinstance(space, dict)
):
@@ -188,6 +192,15 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
# projection at all. The old catalogue guard therefore cannot see the
# destructive downgrade, but v10 must never persist the removed
# carrier again (#478 AC2).
#
# #529: the submitted model number is not the tell. A stale card
# echoes back the `model_version` it was given — the current one — so
# the old `new_model < 10` condition let exactly the outdated client
# through and the user met an unrelated "conflicting wall identifiers"
# instead of "update the card and reload the page". What identifies the
# stale writer is the carrier itself appearing over a stored plan that
# does not have it; legacy left in storage is not a stale writer and is
# healed by the migration instead (#529 K4).
raise WallModelClientOutdatedError(
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
)
@@ -1903,7 +1916,11 @@ def _config_wall_segment_invariants(value: dict) -> dict:
if model >= 9:
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
raise vol.Invalid("v9 config must not contain legacy open boundaries")
if model >= 10 and "room_drafts" in space:
# #529: пустой ключ данных не несёт и записи не стоит. Непустой
# остаётся отказом — это последняя линия против устаревшего клиента,
# и к ней человек приходит уже с понятным сообщением от сторожа
# переходов.
if model >= 10 and space.get("room_drafts"):
raise vol.Invalid("v10 config must not contain room_drafts")
segments = space.get("wall_segments")
if segments is None:
@@ -644,14 +644,21 @@ def _host_openings(
opening.pop("host", None)
def _migrate_room_drafts_to_partitions(
space: dict[str, Any], initial_migration: bool,
) -> tuple[int, int]:
def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]:
if "room_drafts" not in space:
return 0, 0
if not initial_migration:
raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")
drafts = space.get("room_drafts") or []
# #529: наследие снимается, а не запирает план. Отказ здесь стоил
# пользователю всего: карточка считает ту же миграцию своим зеркалом, так
# что структурная правка отбивалась ещё до отправки, «Optimize plans» на
# эту ветку не влияет, а экспорт зовёт эту же функцию — забрать бэкап и
# починить файл руками тоже было нельзя. Защита от устаревшего клиента
# (#478) переехала на свой слой: `validate_wall_model_transition` ловит
# заявку с черновиками поверх чистого сохранённого конфига и отвечает
# человеку «обновите карточку», а не загадкой про идентификаторы.
if not drafts:
space.pop("room_drafts", None)
return 0, 0
used = {
str(item["id"])
for name in ("rooms", "openings", "decor", "partitions",
@@ -709,9 +716,7 @@ def _migrate_room_drafts_to_partitions(
def _migrate_space(
space: dict[str, Any], initial_migration: bool,
) -> tuple[int, int, int]:
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(
space, initial_migration,
)
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(space)
old: dict[str, dict[str, Any]] = {}
for segment in space.get("wall_segments") or []:
segment_id = str(segment.get("id", ""))
+63 -10
View File
@@ -142,7 +142,7 @@ try {
longTasks: await longTasks.stop(),
};
};
// Current candidates split Stage 3 out of the initial graph. Comparison
// Current candidates split the experimental Iso runtime out of the initial graph. Comparison
// bundles remain valid: their older monolithic renderer has no preload
// hook and is already available synchronously.
const ensureIsoRuntime = async (card) => {
@@ -249,11 +249,11 @@ try {
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
const failures = [];
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
if (snapshot.isoStageRevision !== '3') failures.push('Stage 3 revision marker is absent');
if (snapshot.isoStageRevision !== '4') failures.push('Stage 4 revision marker is absent');
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
failures.push('one or more Stage 3 overlay roots remained on the floor plane');
failures.push('one or more Stage 4 overlay roots remained outside the low visual plane');
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
@@ -262,11 +262,11 @@ try {
const stage3 = snapshot.stage3RootsByKind[kind];
if (total < 1) failures.push(`no rendered ${kind} roots`);
else if (stage3 !== total)
failures.push(`${kind} Stage 3 roots ${stage3}/${total}`);
failures.push(`${kind} Stage 4 roots ${stage3}/${total}`);
}
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
if (!(snapshot.openingSurfaceCounts[kind] > 0))
failures.push(`no Stage 3 ${kind} opening surfaces`);
failures.push(`no Stage 4 ${kind} opening surfaces`);
}
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
@@ -280,7 +280,7 @@ try {
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
if (failures.length)
throw new Error(`Stage 3 ${label} contract failed: ${failures.join('; ')}`);
throw new Error(`Stage 4 ${label} contract failed: ${failures.join('; ')}`);
};
window.__card?.remove?.();
@@ -294,6 +294,41 @@ try {
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
// #520 diagnostics: where the extra half second of model readiness goes.
// Counts Lit update cycles and model builds; printed, never budgeted.
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
// what rebuilds the model a second time (#520).
let diagEpoch = 0;
Object.defineProperty(card, '_cfgEpoch', {
configurable: true,
get: () => diagEpoch,
set: (next) => {
if (next !== diagEpoch) {
const frames = (new Error().stack || '').split('\n').slice(1, 4)
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
}
diagEpoch = next;
},
});
const diagPerform = card.performUpdate.bind(card);
card.performUpdate = function () {
const started = performance.now();
const result = diagPerform();
card.__diag.updates += 1;
card.__diag.updateMs += performance.now() - started;
return result;
};
const diagBuild = card._buildModel.bind(card);
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
if (typeof card._adoptStructuralResponses === 'function') {
const legacy = card._adoptStructuralResponses.bind(card);
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
} else if (typeof card._adoptAuthoritative === 'function') {
const gated = card._adoptAuthoritative.bind(card);
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
}
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
@@ -363,6 +398,14 @@ try {
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const bootDiag = {
updates: card.__diag.updates,
updateMs: Number(card.__diag.updateMs.toFixed(1)),
models: card.__diag.models,
adopts: card.__diag.adopts,
cfgEpoch: card._cfgEpoch,
epochs: card.__diag.epochs.slice(0, 8),
};
const initialProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && initialProjection !== 'iso')
@@ -451,7 +494,7 @@ try {
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
}
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
throw new Error(`${profile} rebuilt Stage 3 structure for an HA-only state update`);
throw new Error(`${profile} rebuilt Stage 4 structure for an HA-only state update`);
let stage3States = nextStates;
const openingUpdate = stage3Dense ? await duration(async () => {
@@ -469,7 +512,7 @@ try {
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback during an opening update`);
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
throw new Error(`${profile} rebuilt Stage 3 structure for an opening update`);
throw new Error(`${profile} rebuilt Stage 4 structure for an opening update`);
const overlayInteraction = stage3Dense ? await duration(async () => {
const overlay = card.renderRoot.querySelector(requireStage3
@@ -495,7 +538,7 @@ try {
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
if (requireStage3
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
throw new Error(`${profile} rebuilt Stage 3 structure for a hover/focus update`);
throw new Error(`${profile} rebuilt Stage 4 structure for a hover/focus update`);
}
let interactionDiagnostics = null;
@@ -1096,6 +1139,7 @@ try {
const result = {
sample,
bootDiag,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
@@ -1165,7 +1209,16 @@ try {
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, profile,
});
if (measuredSample >= 0) rows.push(row);
// #520: диагностика печатается в лог прогона и в запись не попадает.
const { bootDiag, ...measured } = row;
if (bootDiag) {
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
}
if (measuredSample >= 0) rows.push(measured);
}
} finally {
await browser.close();
+11
View File
@@ -43,6 +43,17 @@ radial spokes visible instead of hiding them under a translucent room fill.
CI turns green"; the second stops an empty or clipped frame from becoming the
contract unseen (#350).
## Displayed version (#512)
Frames never show the real `CARD_VERSION`. The harness sets the test seam
`window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before any card is created and
feeds the same constant wherever it plays the backend (`integration_version`,
support facts), so the about dialog, the version-recovery banner and the support
and backup previews print `0.0.0-golden` in every baseline. A beta bump therefore
changes no golden frame; the version-mismatch scenarios keep their own
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
product never sets the seam (`src/card-version.ts`).
## Workflow
Build and copy the exact current source first:
+28 -28
View File
@@ -1,21 +1,21 @@
{
"schema": 1,
"matrixVersion": 61,
"acceptedAt": "2026-09-09T07:34:37.356Z",
"sourceFingerprint": "4c289ff2fbf0a2df9cd9ca8f6f3c286d9d2c67019a1ced4be717a7d101800575",
"acceptedAt": "2026-09-14T14:31:43.727Z",
"sourceFingerprint": "3bc41a18634ab9cd312dbbd6b4376ac85c55b70b5ad6928e5f7e07643e67aabf",
"chromium": "151.0.7922.34",
"platform": "linux",
"platform": "win32",
"witnesses": {
"count": 112,
"count": 101,
"floor": 10
},
"scenarios": {
"panel-wide-view-light-en": "2c93497871bc40f6401f6cb7e65e73bf98d0064bd2755ca2026135975bd2d9f3",
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
"version-mismatch-desktop-dark-en": "8a693b1b676276ceef8c30dbd8dbccb375a4ce856690e1f2beb182242503743c",
"version-mismatch-touch-light-ru": "6aa0d115da2ec6ba9081a57eb739b7531f860a4ad7a9716ff270bce7e82e803b",
"version-mismatch-kiosk-attempted-dark-de": "848b05c62360b532e3105212ea76730bd02320672d341e2cef90875bac7d87fd",
"version-mismatch-desktop-dark-en": "b8059b768284208f605329618799d13491655e488e9d739a40b85cdc0eba5888",
"version-mismatch-touch-light-ru": "df42d0158025371b82eabd522e0dd7d0b9121a9ba685970db3d7839d97fce50f",
"version-mismatch-kiosk-attempted-dark-de": "2e3eaf2fb13d1cca77abe97938374b3e25235fc7db87bb6f7a2e72f1cafe4a7e",
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
"coincident-partition-before-dark": "32d6d7e32db1bb579b33c783833ce986d10a81589e0783c5e7fae4b8c1e2755e",
@@ -27,22 +27,22 @@
"split-corner-wall-thin-dark": "0a8da611ecb6061852cc26ee53c00d12433cf8539681bd91dac3adad1d7e9e67",
"split-corner-wall-thick-dark": "2efc65ddaa8777ebffe63205e2a961fa7053c91d3cd8c0af59810bef01ecd498",
"split-zero-divider-taper-dark": "f7f9ee435da00dcae043c693127561a5e050675ca4d6f6f0d916ac5dbe24a594",
"isometric-geometry-view-dark": "b061d186ffa614b97e9b0a2ba5f4b5378df54d75e168e5734d94c90289b4cfb0",
"isometric-geometry-view-light": "585fffbb49dab8de8ca2c2af7d4d290c5355b9060029ea9bf38c72af594f2d5f",
"isometric-live-layers-dark": "bf5dbe438ba74fada375966757620a6f8fc4641901f69000862145ca806085ed",
"isometric-no-borders-dark": "792c221f9412825b80c56f5a195f896e3459298caf1623caebb3ad448463fe8d",
"isometric-touch-kiosk-dark": "e609a2abba29e5441d57452b4f9d16a582f8f466f1a6f08cf81ef9f2a5391203",
"isometric-large-warm-remount-dark": "fadfe8985736d3fdec62c971caceb34a91f9ba5698e27380e014dcd93f54a29b",
"isometric-stage3-overlays-light": "9461cb2030234125b73beecc222a6119fdbd7f9d22b8dcec2bad6712210982bd",
"isometric-stage3-overlays-dark": "ef589656803ebbb6436228b068df1ed99fc46c4313f15dc75940835506714f4f",
"isometric-stage3-openings-dark": "7816adef15f9ea83a70572274baa6083875851fdeb17d9a8a77a4fe4feb92faa",
"isometric-stage3-forced-colors-dark": "dd52ebc54962541c934b94dd7f436183ed7a65445bc90f201a2f9d2eefa93fa1",
"isometric-stage3-no-filter-dark": "b2eeda001fa1d72c74aa0a06a996df205a7398dfc9f729406939a49a4c009a21",
"isometric-geometry-view-dark": "2be45cda07281a2d7cce8afd00096e29ef9e424a6c4eff9bf01ac51d9ce1edc7",
"isometric-geometry-view-light": "9ba4fc763db9e5c89163faf8c4c18fb22bff61107689d4bf9bb8cfd518a3b9a6",
"isometric-live-layers-dark": "32ab62d8f04729732cae539b304f389aa1665db2d4a6c017f040ff68d8c61092",
"isometric-no-borders-dark": "ec8952f4bbd4011aed80cf66c9fa8345fe4f91fecfe0c4d9f33c5ae3e210805d",
"isometric-touch-kiosk-dark": "ec61f74130cf5c1359e87a5bd26cf311e5c97d0898f85ca9b08e54386f961dc1",
"isometric-large-warm-remount-dark": "24250bbbbfb9b9487c4e4522f230951da32502aa4c0489cc9e5765b65d31c1b0",
"isometric-stage3-overlays-light": "753511b47a41c41d92504eac6b658b02c3af43a2bd0be438deb7315d1eac6546",
"isometric-stage3-overlays-dark": "d9692ba94647ee4782277e198b75a6e2e7a6a0bfc33e3999d3c8101534327e83",
"isometric-stage3-openings-dark": "6c0b2dd0c95d7a1e2a0bf98d33da06da59557fc249a41e6c35b305562c6a1d67",
"isometric-stage3-forced-colors-dark": "20e1704292a9c25729e7d042926e44c7def8855232b3426dd17e994c1fd92167",
"isometric-stage3-no-filter-dark": "f927ce752808ecc1175ab98eae0d8a734d42746340e06ed31f597a04829dce6e",
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
"pdf-export-geometry-light": "c403a141cefb73e4653f2ffb9c346b4cfaa3de339b6c39c227ef1552ebde0048",
"pdf-export-polish-light": "ed6685900355e20a4618e931d42d2e45a39ad44e4a2ab044fa5ee64ff56f12f4",
"pdf-export-stepped-dimensions-light": "b9732b8b9c38153c564d0c93fbfbf44a8dd348c8667d2f8c2d14492e08e04f3e",
"vacuum-trail-smoothing-dark": "f8eecc726a3e8bdaba414584ad6afd48908ee9d96ea969a1e37758f4e76843e2",
"room-label-parity-view-dark": "a188d4db84533d605cb6fe822d20c34d164e3bfc8458a55beaad564854eef169",
"room-label-parity-plan-dark": "07e896d4f6dad384bd52dc60f786f042e1cfcd26ddb1f7b12b72d40bb4824324",
@@ -50,10 +50,10 @@
"room-label-parity-plan-light": "91b7aed42c3a677e387b26c405a04dcc327957f9368625c7c3ad3cca758f1fb1",
"washer-active-cycle-dark": "0d683619c27747b3dd6a1e6c7cdc5e15dd886f4f2db4b85b7ca5a5c529e9ec52",
"washer-idle-cycle-dark": "0138902daa8dabb0588904507ef5a44f5257f7859b4f1198191bfca3dd4130b7",
"day-cycle-dawn-dark": "6adecb5c2b2c14122140b87f41eb7222c9db4b185eee71a6008209fa8fc42ebc",
"day-cycle-day-dark": "6319379d476f56c91a28e44981fe97883dedfe9e73dd942107328855845e3009",
"day-cycle-dusk-dark": "7039204aa1d66fe8cc30f698e44ad8da7a8550d15aa849720c500b4d4810ac00",
"day-cycle-night-dark": "dc7514e39b37903c9704be833b553d713295b63fc6530fb975a86481f32be6a2",
"day-cycle-dawn-dark": "96bd5ef84345976bb83d35d418e7d247503629eceb732b6e926f2217f3b9faa4",
"day-cycle-day-dark": "605849bbada8d3c3a02a2c88c7b21c81c675a7a89b72c4c560df635d63005e67",
"day-cycle-dusk-dark": "d19cc97b75014d9f0661705df50d5f30a3a574661957c04c5dab96e26c5e3364",
"day-cycle-night-dark": "39a6a959676295eb4ead02703533641f57901ad0ce1359933e1d47f48867196a",
"geometry-plan-editor-dark": "6fa29c51ac37449e53ff76afe17760a86325e160f53040c737619aae1095599d",
"safe-resize-handles-clamp-light": "98db89eb1ec29aa6297ac0bc10653c8efce16175833091d0e76dd033ad9f4a8e",
"safe-resize-handles-clamp-dark": "581f85ca93197ba796e72daa67402eebb7c64f13d4722b5d33943577837c8980",
@@ -72,14 +72,14 @@
"orthogonal-strip-cell-5-view-dark": "267a3eab1f4ad7d8d484eb2a58b3e38031b0516ca8c46c93e272bfaf9ac409b8",
"orthogonal-strip-cell-1-view-dark": "7c9bab0789f9ec9019edf3c49176415af83e8a325e833f98a0e6324e7f06fe4c",
"wall-key-roundtrip-view-dark": "74fd46cffefd32d805296d844c162dbe6f15b13e86c4d6c7f6974293b253e1b4",
"isometric-wall-junctions-dark": "2249ee0effc2c96c90add934071ccab302f9233920b43496a48c8d8ecad298d1",
"isometric-wall-junctions-dark": "fa84bbdb917d6d2fa9376fa01f50e2050994b9f7086a9ce58f8a239bc9afee37",
"opening-placement-door-thick-wall-dark": "9d587eff0c2561ef5f84e9280cd812fa6e9af7cf6ff00c922d275796774ba513",
"opening-placement-passage-thick-wall-dark": "6ed48b39c0e11fbf4b0df7d3e4dad8078e5b17694f1a780936a864bebd0c1c57",
"opening-placement-passage-thick-wall-light": "3d4e3a5d9b67f2450fab9ccc0f5dc91f4485080582377de904f16ea1d62fbafa",
"opening-symbol-room-wall-light": "dc3f6513a08b809a77df6822f59fdb30c912f320e55d47d749963678fbb8f417",
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
"isometric-opening-symbol-parity-dark": "4f745055f3de80c01d7144cdc1610be73c78a98f69506c8d6e62bc3fef509471",
"isometric-opening-symbol-parity-dark": "7a64ac0c10e8d76fd902a015e2e3a629414f93878e72ed8f32510f157775cc7a",
"geometry-devices-editor-dark": "e273c4fe49619a5856a9212ff6261803640e5afa79b841edfccf5a4056e0264d",
"device-inbox-desktop-en-light": "2556b2ca3916c016bec58767acc3eab445e32ef29ae59e8ef3580f27cdee9f21",
"device-inbox-desktop-ru-dark": "f6da7aca353c904e37f2aae9bda9ef79b0c102926f9b9dbab5f6d9b7a14bbfb3",
@@ -162,7 +162,7 @@
"general-color-popover-desktop-en": "8dd1aac5beb37d942325c566dd1fedd6303a74d97882202cca92516fc4bd7982",
"settings-help-zoom-200-en-light": "d4c0ca1d0a6807707bc5f3366f4da8d8bba13297d09339d36917fd77f9fbe923",
"settings-help-zoom-200-ru-dark": "5128090ce7ed3196b3cf81e95634c11c9a42160959cac682792920353f8405c7",
"support-desktop-empty-light-en": "a16ebd9246343bdfe363eb1546fab0dfee220cdb746575afb920ffad946648d1",
"support-desktop-empty-light-en": "ec60104cb98b646db22089c93f70f78c3a13cdb7001782b2981ce19800ce3cee",
"support-desktop-preview-dark-en": "37707b251c117eb381f7ddd6ad13a235be82c139a4d92dc07789a76cc425e946",
"support-phone-validation-light-ru": "1699dde634aa583ce6aaa30252f6cc01c9d822ff2718d0d77b179c7c38216ea2",
"support-phone-success-dark-en": "21146b3a221010cdc45e1a83c299395772a9f214550a296b78c5ecb72ddab332",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+35 -29
View File
@@ -32,9 +32,13 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
const wallUnionIsolationFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
));
const cardVersion = JSON.parse(readFileSync(
new URL('../../package.json', import.meta.url), 'utf8',
)).version;
// The version the frames show (#512). The card's own CARD_VERSION changes with
// every beta and used to invalidate every frame that prints it (about, the
// version-recovery banner, support and backup previews) although nothing but
// the digits changed. The harness pins the displayed version through the
// `__HP_VERSION_OVERRIDE__` seam and feeds the same constant wherever it plays
// the backend, so a version bump is invisible to golden by construction.
const cardVersion = '0.0.0-golden';
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
const fixtureFor = (scenario) => scenario.fixture === 'large'
@@ -608,7 +612,7 @@ export function prepareGoldenFixture(scenario) {
const space = requireSpace();
if (contract.roomMetrics) {
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
throw new Error(`golden Stage 3 metrics require lighting fixture: ${space.id}`);
throw new Error(`golden Stage 4 metrics require lighting fixture: ${space.id}`);
space.settings = {
...(space.settings || {}), show_names: true,
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
@@ -621,7 +625,7 @@ export function prepareGoldenFixture(scenario) {
}
if (contract.newDevice) {
if (!fixture.devices?.[contract.newDevice])
throw new Error(`golden Stage 3 new marker is missing: ${contract.newDevice}`);
throw new Error(`golden Stage 4 new marker is missing: ${contract.newDevice}`);
fixture.config.settings = {
...(fixture.config.settings || {}), new_device_ids: [contract.newDevice],
};
@@ -629,14 +633,14 @@ export function prepareGoldenFixture(scenario) {
if (contract.lockOpening) {
const opening = (space.openings || []).find((item) => item.id === contract.lockOpening);
if (!opening || !['door', 'gate'].includes(opening.type))
throw new Error(`golden Stage 3 lock opening is missing: ${contract.lockOpening}`);
throw new Error(`golden Stage 4 lock opening is missing: ${contract.lockOpening}`);
if (!['locked', 'unlocked'].includes(contract.lockState))
throw new Error(`golden Stage 3 lock state is invalid: ${contract.lockState}`);
throw new Error(`golden Stage 4 lock state is invalid: ${contract.lockState}`);
const entityId = `lock.golden_stage3_${contract.lockOpening.replaceAll('-', '_')}`;
opening.lock = entityId;
fixture.states[entityId] = {
entity_id: entityId, state: contract.lockState,
attributes: { friendly_name: `Golden Stage 3 ${contract.lockOpening} lock` },
attributes: { friendly_name: `Golden Stage 4 ${contract.lockOpening} lock` },
};
}
}
@@ -857,6 +861,8 @@ export async function prepareGoldenScenario(page, scenario) {
const fixture = prepareGoldenFixture(scenario);
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
// #512: the displayed version is pinned before any card is created.
window.__HP_VERSION_OVERRIDE__ = cardVersion;
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
@@ -1258,9 +1264,9 @@ export async function prepareGoldenScenario(page, scenario) {
const stage = card.renderRoot.querySelector('.stage');
const structuralBuilds = Number(stage?.getAttribute('data-hp-iso-structural-builds'));
if (!scenario.stage3Golden?.noBorders
&& (stage?.getAttribute('data-hp-iso-stage') !== '3'
&& (stage?.getAttribute('data-hp-iso-stage') !== '4'
|| !Number.isFinite(structuralBuilds) || structuralBuilds < 1)) {
throw new Error(`isometric golden did not render the Stage 3 contract: ${scenario.id}`);
throw new Error(`isometric golden did not render the Stage 4 contract: ${scenario.id}`);
}
}
if (scenario.stage3Golden) {
@@ -1269,7 +1275,7 @@ export async function prepareGoldenScenario(page, scenario) {
const renderedStage = card.renderRoot.querySelector('.stage');
if (card.hass?.themes?.darkMode !== expectedDark
|| !renderedStage?.classList.contains(expectedDark ? 'theme-dark' : 'theme-light')) {
throw new Error(`Stage 3 golden did not use HA darkMode: ${scenario.id}`);
throw new Error(`Stage 4 golden did not use HA darkMode: ${scenario.id}`);
}
const raisedRoots = [...card.renderRoot.querySelectorAll(
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
@@ -1280,42 +1286,42 @@ export async function prepareGoldenScenario(page, scenario) {
if (contract.noBorders) {
if (raisedRoots.length || raisedSvg
|| card.renderRoot.querySelector('[data-hp-iso-raised="true"]')) {
throw new Error(`Stage 3 no-borders golden retained raised overlays: ${scenario.id}`);
throw new Error(`Stage 4 no-borders golden retained low overlays: ${scenario.id}`);
}
} else {
const missing = (contract.requiredKinds || []).filter((kind) => !raisedKinds.has(kind));
if (missing.length) {
throw new Error(`Stage 3 golden is missing raised kinds ${missing.join(',')}: ${scenario.id}`);
throw new Error(`Stage 4 golden is missing low-overlay kinds ${missing.join(',')}: ${scenario.id}`);
}
if (!raisedRoots.length
|| raisedRoots.some((root) => root.getAttribute('data-hp-iso-raised') !== 'true')) {
throw new Error(`Stage 3 golden left an interactive overlay on the floor: ${scenario.id}`);
throw new Error(`Stage 4 golden lost the corrected low visual plane: ${scenario.id}`);
}
if (contract.requireNudged
&& !raisedRoots.some((root) => root.getAttribute('data-hp-iso-nudged') === 'true')) {
throw new Error(`Stage 3 golden fixture produced no bounded nudge: ${scenario.id}`);
throw new Error(`Stage 4 golden fixture produced no bounded nudge: ${scenario.id}`);
}
}
const materialDefs = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]').length;
if (contract.requireMaterialDefs
&& (!(materialDefs > 0) || materialDefs > contract.maxMaterialDefs)) {
throw new Error(`Stage 3 golden material definitions ${materialDefs} are outside 1..${contract.maxMaterialDefs}: ${scenario.id}`);
throw new Error(`Stage 4 golden material definitions ${materialDefs} are outside 1..${contract.maxMaterialDefs}: ${scenario.id}`);
}
if (contract.requireNoMaterialDefs && materialDefs !== 0) {
throw new Error(`Stage 3 solid fallback retained material definitions: ${scenario.id}`);
throw new Error(`Stage 4 solid fallback retained material definitions: ${scenario.id}`);
}
if (contract.requireTetherCues) {
const tethers = [...card.renderRoot.querySelectorAll('.iso-overlay-tether')];
if (contract.requireNoTetherCues) {
const tether = card.renderRoot.querySelector('.iso-overlay-tether');
const paintedFootprint = card.renderRoot.querySelector(
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
);
if (paintedFootprint || !tethers.length) {
throw new Error(`Stage 3 golden lost invisible-footprint/tether contract: ${scenario.id}`);
if (paintedFootprint || tether) {
throw new Error(`Stage 4 golden retained a debug footprint/tether cue: ${scenario.id}`);
}
}
if (contract.requireGrounding
&& card.renderRoot.querySelectorAll('.iso-overlay-ground').length < raisedRoots.length) {
throw new Error(`Stage 3 golden lost persistent grounding cues: ${scenario.id}`);
if (contract.requireNoGrounding
&& card.renderRoot.querySelector('.iso-overlay-ground, #hp-iso-overlay-ground')) {
throw new Error(`Stage 4 golden retained a per-marker ground cue: ${scenario.id}`);
}
if (contract.requireDenseFacets) {
const facets = {
@@ -1327,7 +1333,7 @@ export async function prepareGoldenScenario(page, scenario) {
};
const missing = Object.entries(facets).filter(([, count]) => !(count > 0));
if (missing.length) {
throw new Error(`Stage 3 dense golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
throw new Error(`Stage 4 dense golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
}
}
if (contract.openingKinds) {
@@ -1336,7 +1342,7 @@ export async function prepareGoldenScenario(page, scenario) {
)].map((node) => node.getAttribute('data-kind')).filter(Boolean));
const missing = contract.openingKinds.filter((kind) => !renderedKinds.has(kind));
if (missing.length) {
throw new Error(`Stage 3 opening golden is missing ${missing.join(',')}: ${scenario.id}`);
throw new Error(`Stage 4 opening golden is missing ${missing.join(',')}: ${scenario.id}`);
}
}
if (contract.requirePassage) {
@@ -1344,7 +1350,7 @@ export async function prepareGoldenScenario(page, scenario) {
|| card.renderRoot.querySelector(
'[data-hp="iso-openings"] .iso-opening-panel[data-kind="passage"]',
)) {
throw new Error(`Stage 3 passage golden violated negative-volume contract: ${scenario.id}`);
throw new Error(`Stage 4 passage golden violated negative-volume contract: ${scenario.id}`);
}
}
if (contract.requireVacuumFloor) {
@@ -1353,7 +1359,7 @@ export async function prepareGoldenScenario(page, scenario) {
if (!vacuumRoots.length || vacuumRoots.some((root) =>
root.getAttribute('data-hp-iso-raised') === 'true'
|| root.hasAttribute('data-hp-iso-overlay-kind'))) {
throw new Error(`Stage 3 golden raised a floor-bound vacuum: ${scenario.id}`);
throw new Error(`Stage 4 golden raised a floor-bound vacuum: ${scenario.id}`);
}
}
}
@@ -2112,7 +2118,7 @@ export async function prepareGoldenScenario(page, scenario) {
};
const missing = Object.entries(live).filter(([, count]) => !(count > 0));
if (missing.length) {
throw new Error(`Stage 3 combined golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
throw new Error(`Stage 4 combined golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
}
}
let panelHost = null;
+4 -4
View File
@@ -296,8 +296,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
requireMaterialDefs: true, maxMaterialDefs: 12,
requireVacuumFloor: theme === 'dark',
requireDenseFacets: true,
requireTetherCues: true,
requireGrounding: true,
requireNoTetherCues: true,
requireNoGrounding: true,
requireLiveLayers: theme === 'dark',
},
markerOverrides: stage3DenseMarkers,
@@ -350,7 +350,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
stage3Fixture: { roomMetrics: true, lockOpening: 'light-door', lockState: 'locked' },
stage3Golden: {
requiredKinds: stage3RequiredOverlays, requireNudged: true,
requireNoMaterialDefs: true, requireTetherCues: true,
requireNoMaterialDefs: true, requireNoTetherCues: true, requireNoGrounding: true,
},
layoutOverrides: stage3DenseLayout,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
@@ -360,7 +360,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
stage3Fixture: { roomMetrics: true, lockOpening: 'light-door', lockState: 'locked' },
stage3Golden: {
requiredKinds: stage3RequiredOverlays, requireNudged: true,
requireNoMaterialDefs: true, requireTetherCues: true,
requireNoMaterialDefs: true, requireNoTetherCues: true, requireNoGrounding: true,
},
layoutOverrides: stage3DenseLayout,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
+14 -1
View File
@@ -121,7 +121,9 @@ one runner. Its raw reports and comparison are uploaded as
`full-performance-<profile>`, and the table is written to that GitHub job
summary. Stable release assets require both exact-SHA
`Validate` and exact-SHA `Full Performance`; prereleases require only
`Validate`.
`Validate`. The gate (`scripts/release-gate.mjs`, #511) judges by the latest
non-cancelled run on the SHA: a re-run or a manual comparison against another
baseline refreshes the verdict, and a cancelled twin is invisible.
This base-vs-candidate design intentionally does not compare timings captured
on different machines or different Chromium builds. A runtime/profile mismatch
@@ -218,6 +220,17 @@ rationale in the change. Do not loosen a threshold merely to make a single red
run pass. A new fixture profile gets a new profile id instead of silently
changing the meaning of `large-house-v1`.
`large-house-isometric-v1` and its Stage 3 dense twin carry
`countNoiseAllowance: 5` (the other profiles keep 3). Owner decision 2026-09-09, #507: since v1.73.0-beta.1 the
isometric renderer lives in the lazy `iso-scene-render` chunk (#160 Stage 3),
so the single v1.72.0 boot task is split into two around that import. The
load-phase work is unchanged — timings, `longTask.totalP95Ms` and
`longTask.maxSingleMs` keep their unchanged ratios and still catch real
growth — but `longTask.countP95` counts the split as +2 tasks and, with
runner jitter, sat at 16 → 20 against a 19.2 limit on the v1.73.0 stable
comparison. The allowance widens the count check alone to 16 → 21 for that
baseline; it is not a licence for more work per task.
The `cleanFloor` entry ceiling is 100: the reviewed fixture warms exactly 100
deterministic room/physical-body entries. An extra 20 means that one complete
floor was invalidated and rebuilt, so fixture extensions must recalibrate this
@@ -21,7 +21,7 @@
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"countNoiseAllowance": 5,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
@@ -19,7 +19,7 @@
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"countNoiseAllowance": 5,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
+9
View File
@@ -18,6 +18,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
methods: Object.freeze([
'_baseVb',
'_bindingStatus',
'_buildModel',
'_cancelDecorGesture',
'_checkSpacePhysicalGeometry',
'_decorBoxOf',
@@ -40,6 +41,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_booting',
'_bootSoft',
'_cameraTransition',
'_cfgEpoch',
...CACHE_FIELDS,
'_continuity',
'_cursorPt',
@@ -72,13 +74,20 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
// bundles moved it into ResizeController. A comparison target must expose
// one of the two explicit shapes; the current member retains its object
// type check and is also verified against current production source.
// #500 replaced the private structural adoption with a gated public entry
// point. The boot diagnostics (#520) count adoptions through whichever of
// the two the measured bundle owns; an undeclared rename would silently
// report zero adoptions instead of failing.
fieldAlternatives: Object.freeze([
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
Object.freeze({ current: '_adoptAuthoritative', legacy: '_adoptStructuralResponses' }),
]),
fieldTypes: Object.freeze({
_adoptAuthoritative: 'function',
_booting: 'boolean',
_bootSoft: 'boolean',
_cameraTransition: 'object',
_cfgEpoch: 'number',
_cleanFloorCache: 'map',
_devices: 'array',
_continuity: 'object',
+6 -4
View File
@@ -62,10 +62,12 @@ const requireReport = (report, budgets, label, expectedSha = null) => {
throw new Error(`${label}: Iso structural build count is missing or changed on HA-only update`);
}
if (label === 'candidate' && report.profile === 'isometric-stage3-dense-v1') {
if (report.stage3Required !== true) throw new Error(`${label}: Stage 3 was not required`);
if (!sameJson(report.isoStageRevision, ['3'])
|| report.rows.some((row) => row.isoStageRevision !== '3')) {
throw new Error(`${label}: Stage 3 revision metadata is incomplete`);
// The profile/schema name is intentionally stable for baseline continuity;
// the candidate DOM revision tracks the current experimental Stage 4.
if (report.stage3Required !== true) throw new Error(`${label}: Stage 4 was not required`);
if (!sameJson(report.isoStageRevision, ['4'])
|| report.rows.some((row) => row.isoStageRevision !== '4')) {
throw new Error(`${label}: Stage 4 revision metadata is incomplete`);
}
}
};
+46 -3
View File
@@ -4,13 +4,15 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const { page, browser } = await launch(
{ width: 820, height: 760 }, 1, [], { hasTouch: true },
);
const session = await page.context().newCDPSession(page);
const frames = [];
let stopped = false;
session.on('Page.screencastFrame', (event) => {
if (frames.length < 120) frames.push(event.data);
if (frames.length < 180) frames.push(event.data);
void session.send('Page.screencastFrameAck', { sessionId: event.sessionId });
});
@@ -36,6 +38,46 @@ try {
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
});
await wait(120);
// #579: exercise a real multi-frame pinch while CDP records compositor-
// presented frames. DOM screenshots cannot witness the transient white or
// transparent WebView frame reported by users.
const pinchFrameStart = frames.length;
await page.evaluate(async () => {
const card = window.__card;
const stage = (card.shadowRoot || card.renderRoot).querySelector('.stage');
const rect = stage.getBoundingClientRect();
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
stage.setPointerCapture = () => {};
stage.releasePointerCapture = () => {};
const send = (type, pointerId, x, buttons = 1) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true,
composed: true,
pointerId,
pointerType: 'touch',
isPrimary: pointerId === 731,
button: type === 'pointerdown' ? 0 : -1,
buttons,
clientX: x,
clientY: centerY,
}));
send('pointerdown', 731, centerX - 72);
send('pointerdown', 732, centerX + 72);
const distances = [80, 91, 70, 98, 66, 104, 73, 96, 68, 101, 75, 94];
for (const distance of distances) {
send('pointermove', 731, centerX - distance);
send('pointermove', 732, centerX + distance);
await new Promise(requestAnimationFrame);
}
send('pointerup', 731, centerX - distances.at(-1), 0);
send('pointerup', 732, centerX + distances.at(-1), 0);
await card.updateComplete;
await new Promise(requestAnimationFrame);
});
await wait(180);
const pinchPresentedFrames = frames.length - pinchFrameStart;
await page.evaluate(() => window.__card._pageVisibility({
kind: 'visible', token: 73, at: Date.now(), hiddenFor: 20_000, long: true,
}));
@@ -110,11 +152,12 @@ try {
Buffer.from(data, 'base64'),
));
writeFileSync('artifacts/continuity-screencast/metrics.json', JSON.stringify({
stage, frames: metrics, forbidden: forbidden.length,
stage, frames: metrics, forbidden: forbidden.length, pinchPresentedFrames,
}, null, 2));
const result = {
capturedPresentedFrames: frames.length >= 2,
capturedPinchPresentedFrames: pinchPresentedFrames >= 4,
baselineContainsPlanDetail: baseline.variance >= 12 && baseline.mean >= 4,
noEmptyOrBlackPresentedFrame: forbidden.length === 0,
};
+316 -69
View File
@@ -1,80 +1,327 @@
// Направляющие выравнивания — свидетель на НАСТОЯЩЕМ жесте (#521).
//
// Прежняя редакция этого смока была зелёной на сломанном коде и потому сама
// оказалась частью дефекта: она присваивала `_deviceDrag`/`_decorDraft`
// целиком и звала `requestUpdate()`. Присвоение с `oldValue == null`
// маршрутизацию живого пути не включает, поэтому проверялась обычная осевая
// отрисовка — состояние, которого при настоящем жесте не бывает. С #451 всё,
// что двигается пальцем, идёт в живого художника, а слой направляющих жил
// только в осевшей сцене; на пользователе это выглядело как «направляющих
// больше нет» в трёх редакторах сразу.
//
// Отсюда протокол каждого сценария:
// 1. дождаться тишины — сразу после входа в режим `_hdrH` сходится
// ступеньками ~200 мс, и каждая ступенька даёт осевой кадр «в подарок»,
// на котором сломанный код тоже показывает направляющую;
// 2. вести жест настоящими `PointerEvent`/`click` по элементу;
// 3. считать осевые циклы за движения — их обязано быть НОЛЬ, иначе смок
// снова проверяет не тот путь;
// 4. требовать ровно одну группу `.alignguides`: осевая копия слоя на время
// жеста подавлена, две линии (одна из них устаревшая) — дефект.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const g = c._gridPitch;
const guides = () => sr().querySelectorAll('.alignline').length;
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
const v = poly[0];
c._path = [[v[0] + g * 10, v[1] + g * 10]];
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
c.requestUpdate(); await c.updateComplete;
out.drawGuide = guides() >= 1;
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
// нет выравнивания → нет гидов
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
out.noGuideOffAxis = guides() === 0;
// 2) бейдж угла: 45° красит
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
const ml = sr().querySelector('.measurelabel');
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
const ml2 = sr().querySelector('.measurelabel');
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
c._path = []; c._cursorPt = null;
// 3) редактор устройств: drag значка в линию с другим значком
c._setMode('devices'); await c.updateComplete;
const devs = c._devices.filter((d) => d.space === c._space);
const a = devs[0], b = devs[1];
const pa = c._pos(a);
// поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
const pb = c._pos(b);
c._deviceDrag = {
id: b.id, spaceId: b.space, displayName: b.name, pointerId: 74,
source: null, sx: 0, sy: 0, ox: pb.x, oy: pb.y, moved: true,
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
// Считается ВИДИМОЕ. Осевая копия слоя на время жеста гасится прозрачностью
// и остаётся в DOM — если считать узлы, две направляющие подряд (живая и
// осевая, отставшая на шаг) выглядят как одна.
const visible = (selector) => [...sr().querySelectorAll(selector)].filter((node) => {
const layer = node.closest('.hp-editor-only-layer');
return !layer || layer.style.opacity !== '0';
});
const lines = () => visible('.alignline').length;
const dots = () => visible('.aligndot').length;
const groups = () => visible('.alignguides').length;
/** Кто сейчас рисует видимую направляющую: живой художник или осевшая сцена. */
const guideOwner = () => {
const group = visible('.alignguides')[0];
if (!group) return 'none';
return group.closest('[data-hp-live-editor]') ? 'live' : 'settled';
};
const keptDrag = c._deviceDrag;
c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1;
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
// совпадает сама с собой в пределах допуска, поэтому исключение
// перетаскиваемого маркера могло не работать вовсе — и не работало,
// потому что читало `_drag`, который в этом режиме всегда null.
out.devGuideComesFromAnotherMarker = (() => {
const key = (list) => list.map((point) => point.join(','));
const withDrag = key(c._editorRuntime._alignCandidates());
c._deviceDrag = null;
const withoutDrag = key(c._editorRuntime._alignCandidates());
c._deviceDrag = keptDrag;
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
// Сравнение по разнице списков, а не по заранее посчитанной точке:
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
// уже другая.
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
const guideLineX = () => {
const line = visible('.alignline')[0];
return line ? Number(line.getAttribute('x1')) : NaN;
};
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
/** Осевые кадры оседания редактора должны закончиться ДО жеста. */
const quiet = async () => {
for (let i = 0; i < 8; i++) await frame();
await new Promise((resolve) => setTimeout(resolve, 350));
};
const pe = (type, id, extra) => new PointerEvent(type, {
pointerId: id, isPrimary: true, bubbles: true, composed: true, cancelable: true, ...extra,
});
let settled = 0;
const settledWillUpdate = c.willUpdate.bind(c);
c.willUpdate = function (changed) { settled += 1; return settledWillUpdate(changed); };
const g = c._gridPitch;
const stage = () => c._stageEl || sr().querySelector('.stage');
const toClient = (x, y) => {
const rect = stage().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return {
x: rect.left + ((x - v.x) / v.w) * rect.width,
y: rect.top + ((y - v.y) / v.h) * rect.height,
};
};
const pxPerUnit = () => {
const rect = stage().getBoundingClientRect();
return rect.width / c._viewOr(c._baseVb()).w;
};
// ---- 1) редактор устройств: настоящее перетаскивание значка -------------
c._setMode('devices');
await c.updateComplete;
const devs = c._devices.filter((d) => d.space === c._space && !d.virtual
&& d.bindingStatus?.kind !== 'ha_disabled');
const anchor = devs[0];
const dragged = devs[1];
// Обе позиции — на узлах сетки, иначе выравнивание жестом недостижимо.
// Цель ведёт значок и по X (на ось якоря), и по Y — тогда конец
// направляющей отличает живую точку от замороженного снимка.
c._layout = {
...c._layout,
[anchor.id]: { s: c._space, x: (53 * g) / 1000, y: (53 * g) / 1000 },
[dragged.id]: { s: c._space, x: (61 * g) / 1000, y: (69 * g) / 1000 },
};
c.requestUpdate();
await c.updateComplete;
const anchorPos = c._pos(anchor);
const marker = [...sr().querySelectorAll('[data-hp="device"]')]
.find((element) => element.dataset.id === dragged.id);
out.markerIsOnTheStage = !!marker;
const live = () => c._livePos(c._devices.find((d) => d.id === dragged.id));
await quiet();
const box = marker.getBoundingClientRect();
const sx = box.x + box.width / 2;
const sy = box.y + box.height / 2;
marker.dispatchEvent(pe('pointerdown', 77, { clientX: sx, clientY: sy }));
await c.updateComplete;
out.pointerDownStartsTheDrag = !!c._deviceDrag && c._deviceDrag.moved === false;
const settledAtDown = settled;
let clientX = sx;
let clientY = sy;
let oneLayerEveryStep = true;
const targetY = anchorPos.y + g * 8;
for (let step = 0; step < 10; step++) {
const at = live();
const dx = anchorPos.x - at.x;
const dy = targetY - at.y;
if (Math.abs(dx) < g * 0.01 && Math.abs(dy) < g * 0.01) break;
const share = step < 4 ? 1 / (4 - step) : 1;
clientX += dx * share * pxPerUnit();
clientY += dy * share * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
await frame();
await frame();
if (groups() > 1) oneLayerEveryStep = false;
}
const atGuide = live();
out.dragReachedTheAnchorAxis = Math.abs(atGuide.x - anchorPos.x) < 1e-6;
out.devGuideFollowsARealDrag = lines() >= 1 && dots() >= 1;
out.devSingleGuideLayer = groups() === 1 && oneLayerEveryStep;
out.devZeroSettledRendersDuringDrag = settled - settledAtDown === 0;
// Конец направляющей считается от точки жеста: `pt[1] ± 1.5 шага`. Живая
// точка и замороженный снимок здесь заведомо разные — это и есть проверка,
// что направляющую нарисовали от маркера, а не от его прошлого места.
const frozen = c._pos(c._devices.find((d) => d.id === dragged.id));
const over = g * 1.5;
const endFrom = (point) => point.y + Math.sign(point.y - anchorPos.y) * over;
const guideLine = sr().querySelector('.alignline');
const y2 = guideLine ? Number(guideLine.getAttribute('y2')) : NaN;
out.snapshotIsStaleDuringTheDrag = Math.abs(endFrom(frozen) - endFrom(atGuide)) > 1;
out.devGuideIsMeasuredFromTheLivePosition = Math.abs(y2 - endFrom(atGuide)) < 0.01;
out.alignPointIsTheLivePosition = Math.abs(c._alignPoint[0] - atGuide.x) < 1e-6
&& Math.abs(c._alignPoint[1] - atGuide.y) < 1e-6;
out.guideIsPaintedByTheLivePainter = guideOwner() === 'live';
// #400: направляющая обязана идти ОТ ДРУГОГО значка. Проверяется без
// подмены состояния жеста: перетаскиваемого маркера нет среди кандидатов.
const candidates = c._editorRuntime._alignCandidates();
const key = (x, y) => `${Math.round(x * 1000)}:${Math.round(y * 1000)}`;
const candidateKeys = candidates.map((point) => key(point[0], point[1]));
out.devCandidatesExcludeTheDraggedMarker = !candidateKeys.includes(key(atGuide.x, atGuide.y))
&& candidates.length === devs.length - 1;
out.devGuideAnchorIsTheOtherMarker = candidateKeys.includes(key(anchorPos.x, anchorPos.y));
// AC6: серия движений внутри одного кадра — один расчёт кандидатов.
const runtime = c._editorRuntime;
const realCandidates = runtime._alignCandidates.bind(runtime);
let candidateCalls = 0;
runtime._alignCandidates = function () { candidateCalls += 1; return realCandidates(); };
const paintsBefore = c._liveEditorPaintCount;
for (let i = 1; i <= 5; i++) {
marker.dispatchEvent(pe('pointermove', 77, { clientX: clientX + i, clientY }));
}
await frame();
await frame();
const paints = c._liveEditorPaintCount - paintsBefore;
out.candidatesAreComputedOncePerFrame = paints >= 1 && candidateCalls >= 1
&& candidateCalls <= paints;
runtime._alignCandidates = realCandidates;
// Передача слоя посреди жеста. Осевой кадр приходит не от жеста — у
// владельца это делает наблюдатель высоты шапки (`_hdrH` сходится
// ступеньками при любом изменении раскладки), и он же маскировал дефект на
// замерах S2. Каждый осевой рендер заканчивается `_commitLiveEditor()`,
// который гасит живой слой и возвращает владение сцене: двух групп не
// бывает, но точка обязана остаться живой — иначе осевая копия нарисует
// направляющую там, где маркер стоял до жеста.
// Серия AC6 увела маркер с оси на пару шагов — возвращаем его, иначе
// проверять передачу слоя не на чем: без совпадения направляющей нет.
for (let step = 0; step < 8; step++) {
const at = live();
if (Math.abs(at.x - anchorPos.x) < g * 0.01) break;
clientX += (anchorPos.x - at.x) * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
await frame();
await frame();
}
const atHandover = live();
out.markerIsBackOnTheAxis = Math.abs(atHandover.x - anchorPos.x) < 1e-6 && lines() === 1;
const settledBeforeHandover = settled;
c._hdrH = (c._hdrH || 0) + 0.01;
await c.updateComplete;
await frame();
out.handoverHappened = settled - settledBeforeHandover >= 1;
out.handoverGivesTheLayerBackToTheSettledScene = guideOwner() === 'settled'
&& sr().querySelector('[data-hp-live-editor]').childElementCount === 0;
out.handoverKeepsExactlyOneGuide = groups() === 1 && lines() === 1;
out.handoverGuideStaysOnTheLivePoint = Math.abs(guideLineX() - atHandover.x) < 1e-6;
// Настоящее движение — и слой снова у живого художника. Шаг вниз по той же
// оси: выравнивание сохраняется, а позиция меняется, иначе живой отрисовки
// не будет вовсе — менять нечего, и это правильно.
clientY += g * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
for (let i = 0; i < 10 && guideOwner() !== 'live'; i++) await frame();
out.nextMoveTakesTheLayerBack = guideOwner() === 'live' && groups() === 1 && lines() === 1;
// Съехали с оси — направляющей нет. Смещение выбирается так, чтобы не
// попасть на ось ни одного из кандидатов: их девять, и «на глаз» выбранный
// отступ однажды уже сел ровно на чужую ось.
const away = (() => {
const points = c._editorRuntime._alignCandidates();
const here = live();
for (let step = 3; step <= 24; step++) {
const x = here.x + g * step;
const y = here.y + g * step;
const clear = points.every((point) => Math.abs(point[0] - x) > g * 0.5
&& Math.abs(point[1] - y) > g * 0.5);
if (clear) return step;
}
return 0;
})();
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
out.devGuideGone = guides() === 0;
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete;
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
const W = 1000, H = 1000; // square canvas
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
c.requestUpdate(); await c.updateComplete;
out.decorGuide = guides() >= 1;
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
// 5) в Просмотре гидов не бывает
c._setMode('view'); await c.updateComplete;
out.noneInView = guides() === 0;
out.foundAPlaceOffEveryAxis = away > 0;
clientX += g * away * pxPerUnit();
clientY += g * away * pxPerUnit();
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
await frame();
await frame();
out.devNoGuideOffAxis = lines() === 0;
marker.dispatchEvent(pe('pointerup', 77, { clientX, clientY }));
await c.updateComplete;
out.devGuideGoneAfterTheDrop = lines() === 0 && groups() === 0;
// ---- 2) подложка: настоящее рисование прямоугольника -------------------
c._setMode('decor');
await c.updateComplete;
c._curSpaceCfg.decor = [{
id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3,
}];
c._decorTool = 'rect';
c._decorStyle = { color: '#00ff00', width: 3, fill: false };
c.requestUpdate();
await c.updateComplete;
await quiet();
{
const start = toClient(500, 500);
const element = stage();
const settledAtStart = settled;
element.dispatchEvent(pe('pointerdown', 91, { clientX: start.x, clientY: start.y }));
await c.updateComplete;
out.decorGestureStartsADraft = !!c._decorDraft;
let x = start.x;
const y = toClient(0, 600).y;
let decorOneLayer = true;
for (let step = 0; step < 10; step++) {
const b = c._decorDraft?.b;
if (!b) break;
const remaining = 200 - b[0]; // левый край d1
if (Math.abs(remaining) < 0.05) break;
x += remaining * pxPerUnit() * (step < 3 ? 0.5 : 1);
element.dispatchEvent(pe('pointermove', 91, { clientX: x, clientY: y }));
await frame();
await frame();
if (groups() > 1) decorOneLayer = false;
}
out.decorDraftReachedTheShapeCorner = Math.abs((c._decorDraft?.b?.[0] ?? -1) - 200) < 0.05;
out.decorGuideFollowsARealGesture = lines() >= 1 && dots() >= 1;
out.decorSingleGuideLayer = groups() === 1 && decorOneLayer;
out.decorZeroSettledRendersDuringGesture = settled - settledAtStart <= 1;
element.dispatchEvent(pe('pointerup', 91, { clientX: x, clientY: y }));
await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select';
c.requestUpdate();
await c.updateComplete;
}
// ---- 3) план: направляющая идёт за курсором БЕЗ клика ------------------
c._setMode('plan');
c._tool = 'draw';
await c.updateComplete;
await quiet();
{
const room = c._spaceModel().rooms[0];
const poly = room.poly || [[room.x, room.y], [room.x + room.w, room.y],
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
const vertex = poly[0];
const element = stage();
const first = toClient(vertex[0] + g * 10, vertex[1] + g * 10);
element.dispatchEvent(new MouseEvent('click', {
clientX: first.x, clientY: first.y, bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete;
out.planClickAddsTheFirstVertex = c._path.length === 1;
await quiet();
const settledAtHover = settled;
let planOneLayer = true;
for (let step = 1; step <= 4; step++) {
const x = vertex[0] + g * 10 - (g * 10 * step) / 4;
const point = toClient(x, vertex[1] + g * 20);
element.dispatchEvent(pe('pointermove', 93, { clientX: point.x, clientY: point.y }));
await frame();
await frame();
if (groups() > 1) planOneLayer = false;
}
out.planHoverReachedTheVertexAxis = Math.abs((c._cursorPt?.[0] ?? -1) - vertex[0]) < 1e-6;
out.planGuideFollowsTheCursorWithoutAClick = lines() >= 1 && dots() >= 1;
out.planSingleGuideLayer = groups() === 1 && planOneLayer;
out.planZeroSettledRendersDuringHover = settled - settledAtHover === 0;
// Бейдж угла живёт тем же жестом: 45° красит, иначе — обычный градус.
const at45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 5);
element.dispatchEvent(pe('pointermove', 93, { clientX: at45.x, clientY: at45.y }));
await frame();
await frame();
const label45 = sr().querySelector('.measurelabel');
out.badge45 = !!label45 && label45.classList.contains('on45') && label45.textContent.includes('45');
const off45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 2);
element.dispatchEvent(pe('pointermove', 93, { clientX: off45.x, clientY: off45.y }));
await frame();
await frame();
const labelOff = sr().querySelector('.measurelabel');
out.badgeOff45 = !!labelOff && !labelOff.classList.contains('on45')
&& labelOff.textContent.includes('°');
c._path = [];
c._cursorPt = null;
c.requestUpdate();
await c.updateComplete;
}
// ---- 4) в Просмотре направляющих не бывает ----------------------------
c._setMode('view');
await c.updateComplete;
out.noneInView = lines() === 0 && groups() === 0;
return out;
});
checkAll(res);
+368
View File
@@ -0,0 +1,368 @@
// #543: authoritative config reloads may finish in either network/asset order.
// Exercise the production bundle and the real `_reloadConfigOnly` host path;
// only transport replies and image latency are controlled by the harness.
import { launchColdView, checkAll, finish } from './serve.mjs';
const { page, browser } = await launchColdView();
const out = await page.evaluate(async () => {
const card = window.__card;
const clone = (value) => structuredClone(value);
const cache = () => JSON.parse(localStorage.getItem('houseplan_card_cfg_v1') || 'null');
const firstTitle = (config) => config?.spaces?.[0]?.title;
const config = (title, planUrl) => {
const next = clone(card._serverCfg);
next.spaces[0].title = title;
next.spaces[0].plan_url = planUrl;
return next;
};
const deferred = () => {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
};
const original = {
get: card._getAuthoritativeConfig,
prepareImage: card._signer.prepareImage,
cacheSnapshot: card._cacheSnapshot,
rebuild: card._maybeRebuildDevices,
continuity: card._beginContinuityCandidate,
toast: card._showToast,
retry: card._scheduleLoadRetry,
requestUpdate: card.requestUpdate,
};
const tails = {
cache: [], rebuild: 0, continuity: 0, toast: 0, retry: 0, renders: 0,
};
card._cacheSnapshot = function (...args) {
tails.cache.push({ title: firstTitle(this._serverCfg), rev: this._cfgRev });
return original.cacheSnapshot.apply(this, args);
};
card._maybeRebuildDevices = function (...args) {
tails.rebuild += 1;
return original.rebuild.apply(this, args);
};
card._beginContinuityCandidate = function (...args) {
tails.continuity += 1;
return original.continuity.apply(this, args);
};
card._showToast = function (...args) {
tails.toast += 1;
return original.toast.apply(this, args);
};
card._scheduleLoadRetry = function (...args) {
tails.retry += 1;
return original.retry.apply(this, args);
};
card.requestUpdate = function (...args) {
tails.renders += 1;
return original.requestUpdate.apply(this, args);
};
const reset = async (title) => {
const base = config(title, `/assets/${title}.svg`);
card._getAuthoritativeConfig = async () => ({ config: base, rev: 2, can_write: true });
card._signer.prepareImage = async () => true;
await card._reloadConfigOnly(true);
await card.updateComplete;
tails.cache.length = 0;
tails.rebuild = 0;
tails.continuity = 0;
tails.toast = 0;
tails.retry = 0;
tails.renders = 0;
};
const tailSnapshot = () => ({
cache: tails.cache.length,
rebuild: tails.rebuild,
continuity: tails.continuity,
toast: tails.toast,
retry: tails.retry,
renders: tails.renders,
epoch: card._cfgEpoch,
});
// Order 1: A has its response first but remains inside prepareImage while B
// completes. Releasing A must not produce any second adoption tail.
await reset('race-baseline-asset');
const olderAsset = config('race-older-asset', '/assets/race-older-asset.svg');
const newerAsset = config('race-newer-asset', '/assets/race-newer-asset.svg');
const assetGate = deferred();
const assetEntered = deferred();
let requestIndex = 0;
card._getAuthoritativeConfig = async () => (++requestIndex === 1
? { config: olderAsset, rev: 3, can_write: true }
: { config: newerAsset, rev: 4, can_write: true });
card._signer.prepareImage = async (_hass, url) => {
if (String(url).includes('race-older-asset')) {
assetEntered.resolve();
return assetGate.promise;
}
return true;
};
const olderAssetReload = card._reloadConfigOnly(false, 3);
await assetEntered.promise;
await card._reloadConfigOnly(false, 4);
await card.updateComplete;
const assetWinner = {
title: firstTitle(card._serverCfg),
rev: card._cfgRev,
cache: clone(cache()),
cacheTails: tails.cache.length,
rebuilds: tails.rebuild,
continuity: tails.continuity,
renders: tails.renders,
epoch: card._cfgEpoch,
text: card.renderRoot.textContent,
};
assetGate.resolve(true);
await olderAssetReload;
await card.updateComplete;
const assetFinal = {
title: firstTitle(card._serverCfg),
rev: card._cfgRev,
cache: clone(cache()),
cacheTails: tails.cache.length,
rebuilds: tails.rebuild,
continuity: tails.continuity,
renders: tails.renders,
epoch: card._cfgEpoch,
text: card.renderRoot.textContent,
};
// Order 2: A waits in transport itself. B completes before A even reaches
// the common gate; the pre-adoption host check must drop A.
await reset('race-baseline-network');
const olderNetwork = config('race-older-network', '/assets/race-older-network.svg');
const newerNetwork = config('race-newer-network', '/assets/race-newer-network.svg');
const networkGate = deferred();
requestIndex = 0;
card._getAuthoritativeConfig = async () => (++requestIndex === 1
? networkGate.promise
: { config: newerNetwork, rev: 4, can_write: true });
card._signer.prepareImage = async () => true;
const olderNetworkReload = card._reloadConfigOnly(false, 3);
await Promise.resolve();
await card._reloadConfigOnly(false, 4);
await card.updateComplete;
const networkWinner = {
title: firstTitle(card._serverCfg),
rev: card._cfgRev,
cacheTails: tails.cache.length,
rebuilds: tails.rebuild,
continuity: tails.continuity,
renders: tails.renders,
epoch: card._cfgEpoch,
};
networkGate.resolve({ config: olderNetwork, rev: 3, can_write: true });
await olderNetworkReload;
await card.updateComplete;
const networkFinal = {
title: firstTitle(card._serverCfg),
rev: card._cfgRev,
cacheTails: tails.cache.length,
rebuilds: tails.rebuild,
continuity: tails.continuity,
renders: tails.renders,
epoch: card._cfgEpoch,
};
// A local write accepted while a read is pending establishes a different
// baseline. Even a response with the same numeric revision cannot replace
// that body or append another cache/render tail.
await reset('race-baseline-write');
const olderWrite = config('race-older-write', '/assets/race-older-write.svg');
const localWinner = config('race-local-winner', '/assets/race-local-winner.svg');
const writeGate = deferred();
card._getAuthoritativeConfig = async () => writeGate.promise;
const staleAfterWrite = card._reloadConfigOnly(false, 3);
await Promise.resolve();
card._adoption.stageConfigCandidate(localWinner);
card._adoption.acceptConfigWrite(localWinner, { rev: 3 });
card._cacheSnapshot();
card.requestUpdate();
await card.updateComplete;
const writeWinner = {
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
};
const writeTail = tailSnapshot();
writeGate.resolve({ config: olderWrite, rev: 3, can_write: true });
await staleAfterWrite;
await card.updateComplete;
const writeFinal = {
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
tail: tailSnapshot(),
};
// Every lifecycle boundary must invalidate permanently, even when the user,
// connection or route later appears identical again. Snapshot counters only
// after the boundary so its own legitimate work is not attributed to A.
const originalHass = card.hass;
const lifecycleCase = async (name, invalidate) => {
card.hass = originalHass;
await card.updateComplete;
await reset(`race-baseline-${name}`);
const stale = config(`race-stale-${name}`, `/assets/race-stale-${name}.svg`);
const gate = deferred();
card._getAuthoritativeConfig = async () => gate.promise;
const pending = card._reloadConfigOnly(false, 3);
await Promise.resolve();
await invalidate();
const before = {
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
tail: tailSnapshot(),
};
gate.resolve({ config: stale, rev: 3, can_write: true });
await pending;
await card.updateComplete;
const after = {
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
tail: tailSnapshot(),
};
return JSON.stringify(before) === JSON.stringify(after);
};
const routeInvalidated = await lifecycleCase('route', async () => {
card._leaveCardRoute();
});
const userInvalidated = await lifecycleCase('user', async () => {
card.hass = { ...originalHass, user: { ...originalHass.user, id: 'other-user' } };
await card.updateComplete;
card.hass = originalHass;
await card.updateComplete;
});
const connectionInvalidated = await lifecycleCase('connection', async () => {
card.hass = { ...originalHass, connection: { ...originalHass.connection } };
await card.updateComplete;
card.hass = originalHass;
await card.updateComplete;
});
const reconnectInvalidated = await lifecycleCase('reconnect', async () => {
const parent = card.parentNode;
card.remove();
await Promise.resolve();
parent.appendChild(card);
await card.updateComplete;
});
// The same element is usable in the new lifecycle after stale work was
// discarded; lifecycle invalidation is not a permanent numeric-rev ban.
const lifecycleWinner = config('race-lifecycle-winner', '/assets/race-lifecycle-winner.svg');
card._getAuthoritativeConfig = async () => ({ config: lifecycleWinner, rev: 4, can_write: true });
card._signer.prepareImage = async () => true;
await card._reloadConfigOnly(false, 4);
await card.updateComplete;
const newLifecycleCanReload = firstTitle(card._serverCfg) === 'race-lifecycle-winner'
&& card._cfgRev === 4;
// Force/restore owns a fresh generation rather than a process-lifetime
// monotonic clock. A lower authoritative revision is valid there; ordinary
// events then establish a new high-water from that restored baseline. The
// pending event timer below belongs to the old generation and must expire.
const restored = config('race-force-restored', '/assets/race-force-restored.svg');
let deferredEventRetry = null;
const nativeSetTimeout = window.setTimeout;
window.setTimeout = (callback) => {
deferredEventRetry = callback;
return 543;
};
card._writesPending += 1;
try {
await card._reloadConfigOnly(false, 5);
} finally {
window.setTimeout = nativeSetTimeout;
}
card._getAuthoritativeConfig = async () => ({ config: restored, rev: 1, can_write: true });
try {
await card._reloadConfigOnly(true);
} finally {
card._writesPending -= 1;
}
await card.updateComplete;
const forceDuringWriteAcceptedLowerRevision = firstTitle(card._serverCfg) === 'race-force-restored'
&& card._cfgRev === 1;
let expiredRetryReads = 0;
card._getAuthoritativeConfig = async () => {
expiredRetryReads += 1;
return { config: restored, rev: 1, can_write: true };
};
await deferredEventRetry?.();
await Promise.resolve();
const oldDeferredEventExpired = expiredRetryReads === 0;
let ordinaryReads = 0;
card._getAuthoritativeConfig = async () => {
ordinaryReads += 1;
return {
config: config('race-after-restore', '/assets/race-after-restore.svg'),
rev: 2,
can_write: true,
};
};
await card._reloadConfigOnly(false, 1);
const restoredEchoSkipped = ordinaryReads === 0;
await card._reloadConfigOnly(false, 2);
await card.updateComplete;
const restoredHigherEventAccepted = ordinaryReads === 1
&& firstTitle(card._serverCfg) === 'race-after-restore'
&& card._cfgRev === 2;
card._getAuthoritativeConfig = original.get;
card._signer.prepareImage = original.prepareImage;
card._cacheSnapshot = original.cacheSnapshot;
card._maybeRebuildDevices = original.rebuild;
card._beginContinuityCandidate = original.continuity;
card._showToast = original.toast;
card._scheduleLoadRetry = original.retry;
card.requestUpdate = original.requestUpdate;
return {
assetWinnerAdopted: assetWinner.title === 'race-newer-asset' && assetWinner.rev === 4,
assetWinnerVisible: assetWinner.text.includes('race-newer-asset'),
assetWinnerCached: firstTitle(assetWinner.cache?.config) === 'race-newer-asset'
&& assetWinner.cache?.rev === 4,
lateAssetCannotRegress: assetFinal.title === assetWinner.title
&& assetFinal.rev === assetWinner.rev,
lateAssetHasNoTail: assetFinal.cacheTails === assetWinner.cacheTails
&& assetFinal.rebuilds === assetWinner.rebuilds
&& assetFinal.continuity === assetWinner.continuity
&& assetFinal.renders === assetWinner.renders
&& assetFinal.epoch === assetWinner.epoch,
lateAssetCannotRegressCache: JSON.stringify(assetFinal.cache) === JSON.stringify(assetWinner.cache),
lateAssetNotVisible: !assetFinal.text.includes('race-older-asset'),
networkWinnerAdopted: networkWinner.title === 'race-newer-network'
&& networkWinner.rev === 4,
lateNetworkCannotRegress: networkFinal.title === networkWinner.title
&& networkFinal.rev === networkWinner.rev,
lateNetworkHasNoTail: networkFinal.cacheTails === networkWinner.cacheTails
&& networkFinal.rebuilds === networkWinner.rebuilds
&& networkFinal.continuity === networkWinner.continuity
&& networkFinal.renders === networkWinner.renders
&& networkFinal.epoch === networkWinner.epoch,
localWriteTitle: writeWinner.title,
localWriteRev: writeWinner.rev,
localWriteCacheTitle: firstTitle(writeWinner.cache?.config),
localWriteCacheRev: writeWinner.cache?.rev,
staleReadCannotReplaceLocalWrite: writeFinal.title === writeWinner.title
&& writeFinal.rev === writeWinner.rev
&& JSON.stringify(writeFinal.cache) === JSON.stringify(writeWinner.cache)
&& JSON.stringify(writeFinal.tail) === JSON.stringify(writeTail),
routeInvalidated,
userInvalidated,
connectionInvalidated,
reconnectInvalidated,
newLifecycleCanReload,
forceDuringWriteAcceptedLowerRevision,
oldDeferredEventExpired,
restoredEchoSkipped,
restoredHigherEventAccepted,
noStaleToastOrRetry: tails.toast === 0 && tails.retry === 0,
};
});
checkAll(out, {
localWriteTitle: 'race-local-winner',
localWriteRev: 3,
localWriteCacheTitle: 'race-local-winner',
localWriteCacheRev: 3,
});
await finish(browser, out);
+110
View File
@@ -0,0 +1,110 @@
// #526: включённая минификация не меняет смысл таблиц стилей.
//
// Минификатор молчал с самого начала (его отсев искал тег вплотную к бэктику,
// а плагин видит вывод TypeScript с пробелом), поэтому 23 КБ CSS проходят
// сжатие впервые. Риск задачи не в двух строках отсева, а в том, что где-то
// съеден значащий пробел — потомковый комбинатор, аргумент calc(), кавычка.
//
// Судим не глазами и не байтами, а браузером: исходный и минифицированный
// текст кладутся в отдельные CSSStyleSheet, и сериализованные правила обязаны
// совпасть посимвольно. Браузер сам приводит запись к канонической форме,
// поэтому совпадение cssText означает совпадение смысла.
import { readFileSync, readdirSync } from 'node:fs';
import { minifyCssText } from '../scripts/css-template-minifier.mjs';
import { launch, checkAll, finish } from './serve.mjs';
/** Тело статического css-шаблона модуля (интерполяций в них нет, см. ТЗ). */
const templateBody = (source) => {
const match = /(^|[^\w$.])css\s*`/.exec(source);
if (!match) return null;
const start = match.index + match[0].length;
const end = source.indexOf('`', start);
if (end < 0) return null;
const body = source.slice(start, end);
return body.includes('\\') || body.includes('${') ? null : body;
};
const files = [
...readdirSync(new URL('../src/styles/', import.meta.url))
.filter((name) => name.endsWith('.ts'))
.map((name) => [`src/styles/${name}`, new URL(`../src/styles/${name}`, import.meta.url)]),
['src/editor-secondary.styles.ts', new URL('../src/editor-secondary.styles.ts', import.meta.url)],
];
const pairs = [];
for (const [label, url] of files) {
const body = templateBody(readFileSync(url, 'utf8'));
if (!body) continue;
pairs.push({ label, source: body, minified: minifyCssText(body, label) });
}
const { page, browser } = await launch();
const out = await page.evaluate(async (input) => {
const result = {};
// Минификатор по контракту схлопывает пробельные пробеги и убирает пробел
// после запятой. В обычных значениях браузер делает то же самое сам, но
// значение СВОЙСТВА-ПЕРЕМЕННОЙ он хранит текстом как есть, поэтому такие
// правила отличались бы записью при полном совпадении смысла. Сравнение
// нормализует ровно объявленную политику пробелов минификатора — пробельные
// пробеги и пробел рядом с `,`, `:`, `(`, `)` — и ничего больше: пропавший
// потомковый
// комбинатор (`.a .b` → `.a.b`) или съеденный пробел в `calc(100% - 2px)`
// остаются расхождением и валят свидетеля.
const normalize = (text) => text
.replace(/\s+/g, ' ')
.replace(/([,:(])\s/g, '$1')
.replace(/\s\)/g, ')');
const rulesOf = (sheet) => {
const list = [];
const walk = (rules) => {
for (const rule of rules) {
list.push(normalize(rule.cssText));
if (rule.cssRules) walk(rule.cssRules);
}
};
walk(sheet.cssRules);
return list;
};
const mismatches = [];
let comparedRules = 0;
for (const pair of input) {
const before = new CSSStyleSheet();
const after = new CSSStyleSheet();
before.replaceSync(pair.source);
after.replaceSync(pair.minified);
const left = rulesOf(before);
const right = rulesOf(after);
comparedRules += left.length;
if (left.length !== right.length) {
mismatches.push(`${pair.label}: правил ${left.length} против ${right.length}`);
continue;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
mismatches.push(`${pair.label}: правило ${index}\n было: ${left[index].slice(0, 160)}\n стало: ${right[index].slice(0, 160)}`);
break;
}
}
}
result.filesCompared = input.length;
result.enoughFiles = input.length >= 5;
result.rulesCompared = comparedRules;
result.enoughRules = comparedRules >= 500;
result.everyRuleIdentical = mismatches.length === 0;
result.mismatches = mismatches.slice(0, 3);
// Минификация обязана быть не просто безопасной, но и полезной.
const saved = input.reduce((sum, pair) => sum + pair.source.length - pair.minified.length, 0);
result.savedBytes = saved;
result.savesRealBytes = saved > 20000;
return result;
}, pairs);
console.log(JSON.stringify({ ...out, mismatches: out.mismatches }, null, 1));
checkAll(out, {
filesCompared: out.filesCompared,
rulesCompared: out.rulesCompared,
savedBytes: out.savedBytes,
mismatches: [],
});
await browser.close();
finish();
+2 -1
View File
@@ -196,7 +196,8 @@ const out = await page.evaluate(async () => {
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
card._writeChain = Promise.resolve();
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
// #500: the delete path re-reads and adopts through the one gated entry; keep it inert here.
card._adoptAuthoritative = async () => ({ status: 'adopted', spaceChanged: false });
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
card._space = 'another-space';
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
+141
View File
@@ -0,0 +1,141 @@
// Стоимость растеризации дневного цикла — свидетель #532.
//
// Владелец: «в Firefox план тормозит; со статичным цветом фона тормоза
// пропадают». Профиль подтвердил: наш JS спал 89 % времени, а в GPU-процессе
// на кадр уезжало 23 МБ текстур. Виновником оказалась не сцена плана, а
// тройной drop-shadow внешнего контура: группа .hp-paperg несёт одни бумажные
// силуэты и при ховере и панораме не меняется, но фильтр жил в общем слое
// плана — и каждая перерисовка плана заново прогоняла три прохода размытия по
// габариту всего листа. Лечится одной подсказкой will-change: filter, которая
// уводит отфильтрованную бумагу в свой композиционный слой.
//
// Смок проверяет две вещи, и вторая — не время, а ОТНОШЕНИЕ: абсолютные
// миллисекунды зависят от машины и раннера, отношение «дневной цикл к
// статичному фону» — нет. Оба замера идут в одном процессе, на одной странице,
// подряд, поэтому делят между собой и прогрев, и загрузку машины.
//
// Замер аналитики на панораме демо-стенда: 7.9 без подсказки, 0.26 с ней.
// Порог 2.0 стоит примерно посередине по логарифму и даёт запас в обе стороны.
//
// Движок здесь Chromium — он же движок CI и golden. Приговор по Gecko даёт
// профиль Firefox с машины владельца: подсказка это совет движку, и Gecko
// вправе отклонить её по бюджету памяти (layout.css.will-change.budget).
// Смок нужен не вместо того профиля, а чтобы правку нельзя было молча снять.
import { launch, check, finish } from './serve.mjs';
const PAN_STEPS = 60;
const RATIO_CEILING = 2.0;
const { page, browser } = await launch({ width: 1400, height: 900 }, 1);
const cdp = await page.context().newCDPSession(page);
const setBackground = async (mode) => {
await page.evaluate(async (mode) => {
const card = window.__card;
card._serverCfg.settings = { ...(card._serverCfg.settings || {}), bg_mode: mode };
// Пространство со своим bg_mode переопределяет глобальный — снимаем, иначе
// смок мерил бы не то, что переключил.
for (const space of card._serverCfg.spaces) {
space.settings = { ...(space.settings || {}) };
delete space.settings.bg_mode;
}
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 40, rising: false },
} } };
card._cfgRev = (card._cfgRev || 0) + 1;
card.requestUpdate();
await card.updateComplete;
}, mode);
// Переход фильтра длится 1100 мс: замер до его конца мерил бы анимацию.
await page.waitForTimeout(1400);
};
const outlineStyle = () => page.evaluate(() => {
const root = window.__card.shadowRoot || window.__card.renderRoot;
const stage = root.querySelector('.stage');
const group = root.querySelector('.hp-paperg');
if (!group) return null;
const style = getComputedStyle(group);
return {
daycycle: stage.classList.contains('daycycle'),
filter: style.filter,
willChange: style.willChange,
};
});
const stageBox = () => page.evaluate(() => {
const root = window.__card.shadowRoot || window.__card.renderRoot;
const rect = root.querySelector('.stage').getBoundingClientRect();
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
});
/** Одна панорама с зажатой кнопкой; возвращает суммарный RasterTask в мс. */
const measurePan = async () => {
const box = await stageBox();
const events = [];
const collect = ({ value }) => events.push(...value);
cdp.on('Tracing.dataCollected', collect);
await cdp.send('Tracing.start', {
traceConfig: { includedCategories: ['disabled-by-default-devtools.timeline'] },
transferMode: 'ReportEvents',
});
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let step = 0; step < PAN_STEPS; step += 1) {
await page.mouse.move(cx + Math.sin(step / 6) * 140, cy + Math.cos(step / 9) * 90);
// Два кадра на шаг: первый ставит кадр в очередь, второй ждёт, пока он
// действительно отрисуется. Иначе замер считает не работу, а очередь.
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
await page.mouse.up();
await page.waitForTimeout(200);
const complete = new Promise((resolve) => cdp.once('Tracing.tracingComplete', resolve));
await cdp.send('Tracing.end');
await complete;
cdp.off('Tracing.dataCollected', collect);
let micros = 0, tasks = 0;
for (const event of events) {
if (event.name !== 'RasterTask' || event.ph !== 'X' || typeof event.dur !== 'number') continue;
micros += event.dur;
tasks += 1;
}
return { ms: micros / 1000, tasks };
};
const out = {};
// 1. Подсказка стоит там и только там, где стоит фильтр.
await setBackground('daynight');
const dayStyle = await outlineStyle();
out.dayCycleClassOn = dayStyle?.daycycle === true;
out.dayCycleOutlineFiltered = /drop-shadow/.test(dayStyle?.filter || '');
out.dayCycleOutlinePromoted = /filter/.test(dayStyle?.willChange || '');
await setBackground('static');
const staticStyle = await outlineStyle();
out.staticClassOff = staticStyle?.daycycle === false;
out.staticOutlineUnfiltered = (staticStyle?.filter || 'none') === 'none';
// Статичный фон не платит за чужую подсказку ни слоем, ни памятью.
out.staticOutlineNotPromoted = !/filter/.test(staticStyle?.willChange || '');
// 2. Отношение растеризации на настоящей панораме.
const staticPan = await measurePan();
await setBackground('daynight');
const dayPan = await measurePan();
out.rasterTasksObserved = staticPan.tasks > 0 && dayPan.tasks > 0;
const ratio = staticPan.ms > 0 ? dayPan.ms / staticPan.ms : Infinity;
out.rasterRatioWithinBudget = ratio <= RATIO_CEILING;
console.log(JSON.stringify({
staticPanMs: Number(staticPan.ms.toFixed(1)), staticPanTasks: staticPan.tasks,
dayCyclePanMs: Number(dayPan.ms.toFixed(1)), dayCyclePanTasks: dayPan.tasks,
ratio: Number(ratio.toFixed(2)), ceiling: RATIO_CEILING,
}, null, 1));
for (const [name, value] of Object.entries(out)) check(name, value);
await finish(browser, out);
+252
View File
@@ -0,0 +1,252 @@
// #564 AC2: real rendered Icon/Text/Double/legacy faces must own every
// painted cardinal point even when the invisible 44 px floor of a neighbouring
// marker overlaps it. Synthetic rectangles cannot prove the CSS/DOM geometry.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 780, height: 720 });
const setup = await page.evaluate(async () => {
const card = window.__card;
const marker = (id, patch) => ({
...(card._serverCfg.markers || []).find((item) => item.id === id),
id, binding: `device:${id}`, tap_action: 'info', ...patch,
});
const replacements = new Map([
['d_light1', marker('d_light1', { display: 'static_icon' })],
['d_tv', marker('d_tv', { display: 'value' })],
['d_temp', marker('d_temp', {
display: 'badge',
value_badge: {
enabled: true,
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
position: 'right',
},
})],
// No explicit value_badge: temperature plus humidity must take the legacy
// two-reading route and produce .with-legacy in the real renderer.
['d_window', marker('d_window', { display: 'badge' })],
]);
card._serverCfg.markers = [
...(card._serverCfg.markers || []).filter((item) => !replacements.has(item.id)
&& item.id !== 'hit-neighbour'),
...replacements.values(),
{
id: 'hit-neighbour', name: 'Neighbour floor', binding: 'virtual',
display: 'static_icon', tap_action: 'info', space: card._space,
},
];
card._layout = {
...card._layout,
'hit-neighbour': { s: card._space, x: 0.5, y: 0.5 },
};
card.hass = {
...card.hass,
entities: {
...card.hass.entities,
'sensor.window_temperature': {
entity_id: 'sensor.window_temperature', device_id: 'd_window',
platform: 'demo', disabled_by: null,
},
'sensor.window_humidity': {
entity_id: 'sensor.window_humidity', device_id: 'd_window',
platform: 'demo', disabled_by: null,
},
},
states: {
...card.hass.states,
'binary_sensor.window': {
entity_id: 'binary_sensor.window', state: '47',
attributes: {
friendly_name: 'Window humidity', device_class: 'humidity',
unit_of_measurement: '%',
},
},
'sensor.window_temperature': {
entity_id: 'sensor.window_temperature', state: '21.5',
attributes: {
friendly_name: 'Window temperature', device_class: 'temperature',
unit_of_measurement: '°C',
},
},
'sensor.window_humidity': {
entity_id: 'sensor.window_humidity', state: '47',
attributes: {
friendly_name: 'Window humidity', device_class: 'humidity',
unit_of_measurement: '%',
},
},
},
};
card._regSignature = '';
card._cfgEpoch++;
card._maybeRebuildDevices();
// Keep the browser fixture inside the already-authoritative demo registry:
// project two existing active entities into one production-shaped legacy
// DevItem, with humidity as primary and the thermometer icon. That is the
// exact old two-reading condition consumed by renderDeviceFace().
const legacyDevice = card._devices.find((device) => device.id === 'd_window');
if (legacyDevice) {
legacyDevice.primary = 'binary_sensor.window';
legacyDevice.entities = ['binary_sensor.window', 'sensor.living_temp'];
legacyDevice.icon = 'mdi:thermometer';
}
card._visibleDeviceSnapshot = null;
card._candidateDeviceSnapshot = null;
card._renderLife.invalidate();
card._planHassMemo = null;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const shell = (id) => card.renderRoot
.querySelector(`[data-hp="device"][data-id="${id}"] .device-shell`);
return {
icon: shell('d_light1')?.className || '',
text: shell('d_tv')?.className || '',
double: shell('d_temp')?.className || '',
legacy: shell('d_window')?.className || '',
};
});
const ids = {
icon: 'd_light1',
text: 'd_tv',
double: 'd_temp',
legacy: 'd_window',
};
const directions = ['right', 'bottom', 'left', 'top'];
const cases = [];
for (const [type, targetId] of Object.entries(ids)) {
for (const direction of directions) {
const prepared = await page.evaluate(async ({ type, targetId, direction }) => {
const card = window.__card;
const root = card.renderRoot;
const target = root.querySelector(`[data-hp="device"][data-id="${targetId}"]`);
const neighbour = root.querySelector('[data-hp="device"][data-id="hit-neighbour"]');
const shell = target?.querySelector('.device-shell');
const badge = target?.querySelector('.value-badge');
const stage = root.querySelector('.stage');
const cardinalDirections = ['right', 'bottom', 'left', 'top'];
if (!target || !neighbour || !shell || !stage) return { ready: false };
// Put the invisible competitor first in DOM/index order. A resolver
// regressed to "first candidate wins" must therefore fail this smoke.
neighbour.parentElement.prepend(neighbour);
for (const node of root.querySelectorAll('[data-hp="device"]')) {
node.style.display = node === target || node === neighbour ? '' : 'none';
}
for (const name of cardinalDirections) {
shell.classList.remove(`pos-${name}`);
badge?.classList.remove(`pos-${name}`);
}
// Icon/Text have no directional section, so the direction selects the
// tested side of their painted core. Double/legacy additionally rotate
// their real flex capsule through every production CSS direction.
if (shell.classList.contains('with-values')) {
shell.classList.add(`pos-${direction}`);
badge?.classList.add(`pos-${direction}`);
}
const stageRect = stage.getBoundingClientRect();
target.style.left = `${stageRect.width / 2}px`;
target.style.top = `${stageRect.height / 2}px`;
neighbour.style.left = `${stageRect.width / 2}px`;
neighbour.style.top = `${stageRect.height / 2}px`;
card._deviceHits.invalidate();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const frame = target.querySelector('.device-shell-frame').getBoundingClientRect();
const unit = {
right: [1, 0], bottom: [0, 1], left: [-1, 0], top: [0, -1],
}[direction];
const inset = 3;
const point = {
x: unit[0] > 0 ? frame.right - inset
: unit[0] < 0 ? frame.left + inset : frame.left + frame.width / 2,
y: unit[1] > 0 ? frame.bottom - inset
: unit[1] < 0 ? frame.top + inset : frame.top + frame.height / 2,
};
const neighbourFrame = neighbour.querySelector('.device-shell-frame').getBoundingClientRect();
const floorStyle = getComputedStyle(neighbour, '::before');
const floorRadius = Math.max(parseFloat(floorStyle.width), parseFloat(floorStyle.height)) / 2;
const paintedRadius = Math.max(neighbourFrame.width, neighbourFrame.height) / 2;
const distance = Math.min(floorRadius - 2, paintedRadius + 3);
const desired = {
x: point.x + unit[0] * distance,
y: point.y + unit[1] * distance,
};
neighbour.style.left = `${desired.x - stageRect.left}px`;
neighbour.style.top = `${desired.y - stageRect.top}px`;
card._deviceHits.invalidate();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const finalNeighbourFrame = neighbour.querySelector('.device-shell-frame').getBoundingClientRect();
const neighbourCore = neighbour.querySelector('.device-core').getBoundingClientRect();
const centre = {
x: neighbourCore.left + neighbourCore.width / 2,
y: neighbourCore.top + neighbourCore.height / 2,
};
const overlapDistance = Math.hypot(point.x - centre.x, point.y - centre.y);
const deepElementFromPoint = (x, y) => {
let node = document.elementFromPoint(x, y);
while (node?.shadowRoot?.elementFromPoint) {
const deeper = node.shadowRoot.elementFromPoint(x, y);
if (!deeper || deeper === node) break;
node = deeper;
}
return node;
};
const native = deepElementFromPoint(point.x, point.y)
?.closest?.('[data-hp="device"]')?.dataset?.id || null;
return {
ready: true, type, direction, targetId, point,
semantic: card._deviceHitOwnerAt(point.x, point.y)?.id || null,
native,
neighbourBeforeTarget: !!(neighbour.compareDocumentPosition(target)
& Node.DOCUMENT_POSITION_FOLLOWING),
targetPainted: point.x >= frame.left && point.x <= frame.right
&& point.y >= frame.top && point.y <= frame.bottom,
neighbourFloor: overlapDistance <= floorRadius,
neighbourPainted: point.x >= finalNeighbourFrame.left
&& point.x <= finalNeighbourFrame.right
&& point.y >= finalNeighbourFrame.top
&& point.y <= finalNeighbourFrame.bottom,
};
}, { type, targetId, direction });
if (prepared.ready) {
await page.mouse.click(prepared.point.x, prepared.point.y);
prepared.clicked = await page.evaluate(async () => {
const card = window.__card;
await card.updateComplete;
const id = card._infoCard?.id || null;
card._infoCard = null;
card.requestUpdate();
await card.updateComplete;
return id;
});
}
cases.push(prepared);
}
}
const out = {
realFaceKinds: setup.icon.includes('device-shell')
&& !setup.icon.includes('with-values') && !setup.icon.includes('text-shell')
&& setup.text.includes('text-shell') && !setup.text.includes('with-values')
&& setup.double.includes('with-values') && !setup.double.includes('with-legacy')
&& setup.legacy.includes('with-values') && setup.legacy.includes('with-legacy'),
allSixteenCasesRan: cases.length === 16 && cases.every((entry) => entry.ready),
everyPaintedPointOwnsNativeAndSemanticHit: cases.every((entry) =>
entry.neighbourBeforeTarget && entry.targetPainted
&& entry.neighbourFloor && !entry.neighbourPainted
&& entry.native === entry.targetId && entry.semantic === entry.targetId),
everyPaintedPointOwnsClick: cases.every((entry) => entry.clicked === entry.targetId),
};
if (Object.values(out).some((value) => value !== true)) {
console.error(JSON.stringify({ setup, cases }, null, 2));
}
await finish(browser, checkAll(out));
+2 -2
View File
@@ -37,7 +37,7 @@ const res = await page.evaluate(async () => {
if (message.type === 'houseplan/config/get') {
// #397 AC3: the reconnect path reads BOTH answers, and a config that
// differs clears the history for its own reason (`configChanged` in
// `_adoptStructuralResponses`). To isolate the layout question the
// `adoptStructuralResponses`, #500). To isolate the layout question the
// fake server echoes the config the card already holds.
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
}
@@ -294,7 +294,7 @@ const res = await page.evaluate(async () => {
placement: structuredClone(rawProbe) },
});
// #397 AC3: the reconnect path (`_loadFromServer` →
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
// `adoptAuthoritativeGated`, #500) is the OTHER reader of the same value: it
// compares the stored `_layoutContentFingerprint` with the server answer
// instead of recomputing it. It must be exercised right after a write,
// before any reload has had a chance to align the two sides — otherwise
+166 -28
View File
@@ -26,44 +26,182 @@ out.pinchZoomsInPlanEditor = await page.evaluate(() => {
return zoomed && c._path.length === 0;
});
// A child control may stop pointer events before the stage sees the first
// finger. The card-level capture guard must still classify the sequence as a
// pinch and swallow WebKit's synthetic click before it reaches that control.
out.pinchCannotMisclickInteractiveChild = await page.evaluate(async () => {
// A real device is the safety boundary: neither order of a pinch may arm the
// marker's long press, and both click and contextmenu compatibility events stay
// owned by that gesture. Exercise every terminal path because touch browsers
// disagree about pointerup/cancel/capture ordering.
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
c._setMode('view');
await c.updateComplete;
const stage = c._stageEl;
const probe = document.createElement('button');
let clicks = 0;
probe.addEventListener('pointerdown', (ev) => ev.stopPropagation());
probe.addEventListener('pointermove', (ev) => ev.stopPropagation());
probe.addEventListener('pointerup', (ev) => ev.stopPropagation());
probe.addEventListener('click', () => clicks++);
stage.appendChild(probe);
const pointer = (type, id, target, x = id === 51 ? 300 : 400) => target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
clientX: x, clientY: 300,
const marker = c.renderRoot.querySelector('.dev[data-entity]')
|| c.renderRoot.querySelector('.dev');
if (!marker) return {
pinchZoomsFromDevice: false,
stageThenDeviceHoldBlocked: false,
stageThenDeviceContextMenuBlocked: false,
pinchIntermediateClickBlocked: false,
pinchDelayedClickBlocked: false,
pinchTerminalVariantsBlocked: false,
pinchCancelsDeviceLongPress: false,
nextDeliberateDeviceTapWorks: false,
nextDeliberateDeviceLongPressWorks: false,
mouseContextMenuStillWorks: false,
keyboardContextMenuStillWorks: false,
};
let actionCalls = 0;
let moreInfoCalls = 0;
let serviceCalls = 0;
const originalClickDevice = c._clickDevice;
const originalOpenMoreInfo = c._openMoreInfo;
const originalCallService = c.hass.callService;
c._clickDevice = () => { actionCalls += 1; };
c._openMoreInfo = () => { moreInfoCalls += 1; };
c.hass.callService = async () => { serviceCalls += 1; };
c._infoCard = null;
c._tapConfirm = null;
const pointer = (type, id, target, x, pointerType = 'touch') => target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true, pointerId: id, pointerType,
clientX: x, clientY: 300, button: type === 'pointerdown' ? 0 : -1,
}));
const compatibilityClick = () => marker.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, cancelable: true,
}));
const compatibilityContextMenu = (pointerType = 'touch', id = 900) => {
const event = new PointerEvent('contextmenu', {
bubbles: true, composed: true, cancelable: true, pointerId: id,
pointerType, clientX: 300, clientY: 300, button: 2,
});
marker.dispatchEvent(event);
return event.defaultPrevented;
};
const noActivation = () => actionCalls === 0 && moreInfoCalls === 0
&& serviceCalls === 0 && c._infoCard === null && c._tapConfirm === null;
// The missed #563 order: stage first, marker second. Capture has already
// made the sequence multi-touch before the marker's pointerdown handler runs.
c._resetZoom();
const stageFirstZoom0 = c._zoom;
pointer('pointerdown', 41, stage, 400);
pointer('pointerdown', 42, marker, 300);
await new Promise((resolve) => setTimeout(resolve, 620));
const stageThenDeviceHoldBlocked = noActivation();
const activeContextPrevented = compatibilityContextMenu('touch', 42);
const stageThenDeviceContextMenuBlocked = activeContextPrevented && noActivation();
pointer('pointermove', 41, stage, 450);
pointer('pointermove', 42, marker, 250);
const stageThenDeviceZooms = c._zoom > stageFirstZoom0 * 1.5;
pointer('pointerup', 42, marker, 250);
pointer('pointerup', 41, stage, 450);
const postContextPrevented = compatibilityContextMenu('touch', 42);
// An unrelated key is not evidence that a delayed touch click became an
// intentional pointer action. Only the dedicated context-menu key may re-arm
// the keyboard context menu after all contacts have ended.
marker.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true, composed: true, cancelable: true, key: 'Tab',
}));
compatibilityClick();
const stageThenDevicePostContextMenuBlocked = postContextPrevented && noActivation();
// Original #563 order: marker first, stage second. The already armed hold
// must still be cancelled and every compatibility click must remain blocked.
c._resetZoom();
const zoom0 = c._zoom;
pointer('pointerdown', 51, probe);
pointer('pointerdown', 52, stage);
pointer('pointermove', 51, probe, 250);
pointer('pointerdown', 51, marker, 300);
pointer('pointerdown', 52, stage, 400);
pointer('pointermove', 51, marker, 250);
pointer('pointermove', 52, stage, 450);
const pinchZoomed = c._zoom > zoom0 * 1.5;
pointer('pointerup', 51, probe);
pointer('pointerup', 52, stage);
probe.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
const gestureClickBlocked = clicks === 0;
await new Promise((resolve) => setTimeout(resolve, 520));
probe.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
probe.remove();
const ordinaryTapStillWorks = clicks === 1;
const pinchZoomsFromDevice = c._zoom > zoom0 * 1.5;
pointer('pointerup', 51, marker, 250);
compatibilityClick(); // between the two releases
const pinchIntermediateClickBlocked = actionCalls === 0;
pointer('pointerup', 52, stage, 450);
await new Promise((resolve) => setTimeout(resolve, 620));
compatibilityClick(); // deliberately later than the old 500 ms window
const pinchDelayedClickBlocked = actionCalls === 0;
const pinchCancelsDeviceLongPress = noActivation();
const terminalGesture = (firstTerminal, secondTerminal, base) => {
pointer('pointerdown', base, marker, 300);
pointer('pointerdown', base + 1, stage, 400);
pointer(firstTerminal, base + 1, stage, 400); // reverse the first gesture's order
compatibilityClick();
compatibilityContextMenu('touch', base + 1);
pointer(secondTerminal, base, marker, 300);
compatibilityClick();
compatibilityContextMenu('touch', base);
};
terminalGesture('pointerup', 'pointercancel', 61);
terminalGesture('lostpointercapture', 'pointerup', 71);
// A third marker contact is still part of the old navigation-only sequence.
pointer('pointerdown', 75, stage, 400);
pointer('pointerdown', 76, marker, 300);
pointer('pointerdown', 77, marker, 320);
pointer('pointerup', 76, marker, 300);
pointer('pointercancel', 77, marker, 320);
pointer('lostpointercapture', 75, stage, 400);
compatibilityClick();
compatibilityContextMenu('touch', 77);
const pinchTerminalVariantsBlocked = noActivation();
// No timeout: the pointerdown itself, not elapsed time, proves a new intent.
pointer('pointerdown', 81, marker, 300);
pointer('pointerup', 81, marker, 300);
compatibilityClick();
const nextDeliberateDeviceTapWorks = actionCalls === 1;
// A fresh single-touch long press is not delayed or swallowed by the old
// pinch. It opens only House Plan's local card through the existing timer.
c._infoCard = null;
pointer('pointerdown', 82, marker, 300);
await new Promise((resolve) => setTimeout(resolve, 620));
const nextDeliberateDeviceLongPressWorks = c._infoCard !== null
&& moreInfoCalls === 0 && actionCalls === 1 && serviceCalls === 0;
pointer('pointerup', 82, marker, 300);
c._closeInfoCard();
// A real right click has its own mouse pointerdown, which is positive intent
// and must not inherit the completed touch sequence's contextmenu barrier.
pointer('pointerdown', 91, marker, 300, 'mouse');
compatibilityContextMenu('mouse', 91);
pointer('pointerup', 91, marker, 300, 'mouse');
const mouseContextMenuStillWorks = moreInfoCalls === 1;
// Shift+F10 has no pointerdown. Its keydown is equivalent positive evidence
// once all old contacts have ended, but never while a pinch is still active.
pointer('pointerdown', 101, marker, 300);
pointer('pointerdown', 102, stage, 400);
pointer('pointerup', 101, marker, 300);
pointer('pointerup', 102, stage, 400);
marker.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true, composed: true, cancelable: true, key: 'F10', shiftKey: true,
}));
marker.dispatchEvent(new MouseEvent('contextmenu', {
bubbles: true, composed: true, cancelable: true, button: 0,
}));
const keyboardContextMenuStillWorks = moreInfoCalls === 2;
c._clickDevice = originalClickDevice;
c._openMoreInfo = originalOpenMoreInfo;
c.hass.callService = originalCallService;
c._setMode('plan');
await c.updateComplete;
return pinchZoomed && gestureClickBlocked && ordinaryTapStillWorks;
});
return {
pinchZoomsFromDevice: pinchZoomsFromDevice && stageThenDeviceZooms,
stageThenDeviceHoldBlocked,
stageThenDeviceContextMenuBlocked: stageThenDeviceContextMenuBlocked
&& stageThenDevicePostContextMenuBlocked,
pinchIntermediateClickBlocked,
pinchDelayedClickBlocked,
pinchTerminalVariantsBlocked,
pinchCancelsDeviceLongPress,
nextDeliberateDeviceTapWorks,
nextDeliberateDeviceLongPressWorks,
mouseContextMenuStillWorks,
keyboardContextMenuStillWorks,
};
}));
// Robot-map calibration owns the gesture surface. The card-level capture
// guard must still suppress a two-finger misclick, but must not seed or apply
+182
View File
@@ -0,0 +1,182 @@
// Браузерная половина геометрического корпуса #560.
//
// Числовые оракулы корпуса живут в test/geometry-corpus.test.mjs и карточку не
// поднимают. Здесь проверяется ровно то, что без карточки проверить нельзя:
//
// 1. штатный writer ресайза — перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за сдвинутым
// ребром делает хост (`project()` в ResizeController), а не resize.ts;
// 2. хвост цепочки «→ View»: план корпуса действительно рисуется, и важные
// участки (комнаты, проём, штриховка стен) присутствуют при DPR 1 и 2.
//
// Рендер здесь НЕ эталон: сравниваются не пиксели, а присутствие и число узлов
// плюс сохранность модели после жеста. Приёмка #560 прямо запрещает принимать
// текущий рендер за единственный источник истины.
import { readFileSync } from 'node:fs';
import { launch, check, finish } from './serve.mjs';
const corpus = (name) => JSON.parse(readFileSync(
new URL(`../test/fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8',
));
const { page, browser } = await launch();
/** Положить план корпуса в карточку как готовую модель v10. */
const loadCorpus = async (config) => {
await page.evaluate((incoming) => {
const card = window.__card;
const space = incoming.spaces[0];
card._serverCfg.model_version = incoming.model_version;
const target = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
for (const key of ['rooms', 'walls', 'wall_segments', 'openings', 'partitions']) {
if (space[key]) target[key] = structuredClone(space[key]);
else delete target[key];
}
target.cell_cm = space.cell_cm;
for (const key of ['decor', 'wall_columns', 'room_drafts', 'open_spans']) delete target[key];
card._geometryHistory.clear();
card._resize.reset();
card._cfgEpoch++;
card.requestUpdate();
return card.updateComplete;
}, config);
await page.waitForTimeout(60);
};
const enter = async (tool) => {
await page.evaluate((next) => {
const card = window.__card;
if (!card._markup) card._setMode('plan');
card._tool = next;
card._resize.reset();
card.requestUpdate();
return card.updateComplete;
}, tool);
await page.waitForTimeout(40);
};
const settle = () => page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))));
const rendered = () => page.evaluate(() => {
const card = window.__card;
const root = card.renderRoot;
const svg = root.querySelector('.stage svg');
const rooms = [...root.querySelectorAll('[data-hp="room"]')];
const box = svg?.viewBox?.baseVal;
// Каждая комната обязана попадать в кадр: уехавшая за viewBox геометрия
// «нарисована» и невидима одновременно — ровно тот случай, когда скриншот
// зелёный, а пользователь видит пустой план.
const inside = rooms.every((node) => {
const bbox = node.getBBox();
return box ? bbox.x + bbox.width > box.x - 1 && bbox.x < box.x + box.width + 1
&& bbox.y + bbox.height > box.y - 1 && bbox.y < box.y + box.height + 1 : false;
});
return {
rooms: rooms.length,
roomsInsideViewBox: inside,
openings: root.querySelectorAll('[data-hp="opening"],[data-hp="opening-tunnel"]').length,
wallFills: root.querySelectorAll('.wallfill,.wallhatch,[data-hp="wall"]').length,
strokeWidths: [...new Set(rooms.map((node) => getComputedStyle(node).strokeWidth))].length,
};
});
const thicknessMultiset = () => page.evaluate(() => {
const card = window.__card;
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
return (space.walls || []).map((wall) => wall.cm).sort((a, b) => a - b).join(',');
});
const roomPolys = () => page.evaluate(() => {
const card = window.__card;
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
return JSON.stringify(space.rooms.map((room) => [room.id, room.poly]));
});
const pointer = (type, planX, planY, { cx, cy, pointerId = 91 } = {}) =>
page.evaluate((args) => {
const card = window.__card;
const stage = card.renderRoot.querySelector('.stage');
const svg = stage.querySelector('svg');
const point = new DOMPoint(args.planX, args.planY).matrixTransform(svg.getScreenCTM());
const handles = [...card.renderRoot.querySelectorAll('.rszhandle')];
const target = args.cx == null
? handles.find((handle) => handle.getAttribute('aria-disabled') === 'false')
: handles.find((handle) => Math.abs(Number(handle.getAttribute('cx')) - args.cx) < 1
&& Math.abs(Number(handle.getAttribute('cy')) - args.cy) < 1);
target?.dispatchEvent(new PointerEvent(args.type, {
bubbles: true, cancelable: true, pointerId: args.pointerId,
clientX: point.x, clientY: point.y, pointerType: 'mouse',
buttons: args.type === 'pointerup' ? 0 : 1,
}));
return { sent: !!target, handle: target ? [Number(target.getAttribute('cx')), Number(target.getAttribute('cy'))] : null };
}, { type, planX, planY, cx, cy, pointerId });
// --- 1. T-узел с тремя толщинами: рисуется и переживает штатный ресайз -------
await loadCorpus(corpus('c2-t-junction-thicknesses'));
await enter('plan');
await settle();
const c2view = await rendered();
check('corpus.c2.rooms_rendered', c2view.rooms, 3);
check('corpus.c2.rooms_inside_viewbox', c2view.roomsInsideViewBox, true);
check('corpus.c2.wall_fills_present', c2view.wallFills > 0, true);
const c2thickness = await thicknessMultiset();
// Ожидание берётся из самой фикстуры, а не вписывается числом: вписанное число
// разошлось бы с корпусом молча, и проверка стала бы декорацией.
const c2fixture = corpus('c2-t-junction-thicknesses').spaces[0].walls
.map((wall) => wall.cm).sort((a, b) => a - b).join(',');
check('corpus.c2.thickness_multiset', c2thickness, c2fixture);
check('corpus.c2.thickness_variety', new Set(c2fixture.split(',')).size >= 3, true);
await enter('resize');
await settle();
const handles = await page.evaluate(() => [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
.filter((handle) => handle.getAttribute('aria-disabled') === 'false').length);
check('corpus.c2.enabled_handles', handles > 0, true);
const target = await page.evaluate(() => {
const handle = [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
.find((candidate) => candidate.getAttribute('aria-disabled') === 'false');
return handle ? [Number(handle.getAttribute('cx')), Number(handle.getAttribute('cy'))] : null;
});
const down = await pointer('pointerdown', target[0], target[1], { cx: target[0], cy: target[1] });
check('corpus.c2.resize_down_sent', down.sent, true);
check('corpus.c2.resize_dragging', await page.evaluate(() => window.__card._resize.dragging), true);
const step = 1000 / 240; // один шаг решётки в единицах плана
await pointer('pointermove', target[0] + step, target[1], { cx: target[0], cy: target[1] });
await settle();
await pointer('pointerup', target[0] + step, target[1], { cx: target[0], cy: target[1] });
await settle();
await page.waitForTimeout(80);
const c2after = await thicknessMultiset();
// Главный оракул браузерной половины: набор толщин после штатного ресайза тот
// же. Именно эту сохранность контроллер ресайза проверяет сам
// (checkWallRecordsPreserved с exactMultiplicity) и откатывает жест иначе.
check('corpus.c2.thickness_survives_resize', c2after, c2thickness);
const c2rendered = await rendered();
check('corpus.c2.rooms_after_resize', c2rendered.rooms, 3);
check('corpus.c2.inside_viewbox_after_resize', c2rendered.roomsInsideViewBox, true);
// --- 2. Проём у вершины: рисуется и переживает перечитывание -----------------
await loadCorpus(corpus('c4-opening-at-vertex'));
await enter('plan');
await settle();
const c4view = await rendered();
check('corpus.c4.rooms_rendered', c4view.rooms, 2);
check('corpus.c4.opening_rendered', c4view.openings > 0, true);
check('corpus.c4.rooms_inside_viewbox', c4view.roomsInsideViewBox, true);
const c4before = await roomPolys();
// save → reload: тот же конфиг приходит заново, как после перезагрузки вкладки.
await page.evaluate(() => {
const card = window.__card;
card._serverCfg = structuredClone(card._serverCfg);
card._cfgEpoch++;
card.requestUpdate();
return card.updateComplete;
});
await settle();
check('corpus.c4.geometry_survives_reload', await roomPolys(), c4before);
const c4reloaded = await rendered();
check('corpus.c4.rooms_after_reload', c4reloaded.rooms, 2);
check('corpus.c4.opening_after_reload', c4reloaded.openings > 0, true);
await finish(browser, { c2view, c2thickness, c2after, c4view, c4reloaded });
+480
View File
@@ -0,0 +1,480 @@
// Household-сценарии основного View (#560).
//
// Шесть путей домашнего пользователя, а не шесть проверок функций: открыть
// карточку на проснувшемся экране, найти тревогу, прочитать значение, выполнить
// разрешённое действие, переключить этаж, вернуться после обновления. У каждого
// названы персона, вход, действие и НЕЗАВИСИМЫЙ наблюдаемый оракул: где можно —
// не «класс появился», а вызов сервиса, доступное имя или содержимое модели.
//
// Две ширины, потому что домашний пользователь живёт в двух разных местах:
// настенный планшет (штатная ширина демо-стенда, 780 px) и узкая колонка
// телефона (390 px) — та самая, на которой ломается первый контакт.
//
// Граница честности: это синтетический стенд. Реальная установка владельца в
// этом заходе читалась только на чтение (профиль в docs/QUALITY-560.md),
// добровольцев и физического Companion в проверке не было.
import { launch, check, finish, watchPage } from './serve.mjs';
const { page, browser } = await launch();
const out = {};
const settle = () => page.evaluate(() => new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))));
/** Геометрия карточки успокоилась: два одинаковых замера подряд (#533). */
const settledBox = async (id, attempts = 25) => {
let previous = null;
for (let i = 0; i < attempts; i++) {
await page.waitForTimeout(120);
const now = await page.evaluate((markerId) => {
const node = window.__card.renderRoot.querySelector(`[data-hp="device"][data-id="${markerId}"]`);
if (!node) return null;
const rect = node.getBoundingClientRect();
return [Number((rect.x + rect.width / 2).toFixed(2)), Number((rect.y + rect.height / 2).toFixed(2)),
Number(rect.width.toFixed(2))];
}, id);
if (previous && now && previous[0] === now[0] && previous[1] === now[1]) return now;
previous = now;
}
return previous;
};
const setWidth = async (px) => {
await page.evaluate(async (width) => {
const card = window.__card;
card.style.width = `${width}px`;
card._lastValidStageSize = null;
card._refitView?.();
await card.updateComplete;
}, px);
await settle();
};
// --- J1. «Экран проснулся» ---------------------------------------------------
// Персона: домашний пользователь у настенного планшета. Вход: вкладка, которая
// никогда не загружала редакторский чанк. Оракул: план показан (вуаль снята),
// комнаты и устройства нарисованы, редакторский чанк не запрашивался.
const requested = [];
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
const j1 = await page.evaluate(() => {
const card = window.__card;
const root = card.renderRoot;
return {
booting: card._booting,
rooms: root.querySelectorAll('[data-hp="room"]').length,
devices: root.querySelectorAll('[data-hp="device"]').length,
stageVisible: getComputedStyle(root.querySelector('.stage')).visibility,
};
});
out.j1 = j1;
check('j1.plan_revealed', j1.booting === false && j1.stageVisible === 'visible', true);
check('j1.rooms_drawn', j1.rooms > 0, true);
check('j1.devices_drawn', j1.devices > 0, true);
// --- J2. «Где протечка» ------------------------------------------------------
// Вход: датчик протечки уходит в тревогу. Оракул независимый: доступное имя
// маркера называет состояние, а не только цвет пятна.
const j2 = await page.evaluate(async () => {
const card = window.__card;
const leak = card._devices.find((device) => device.id === 'd_leak') || card._devices[0];
const entity = leak.primary || Object.keys(card.hass.states)[0];
card.hass = { ...card.hass, states: { ...card.hass.states, [entity]: { ...card.hass.states[entity], state: 'on' } } };
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 150));
const node = card.renderRoot.querySelector(`[data-hp="device"][data-id="${leak.id}"]`);
return {
id: leak.id,
label: node?.getAttribute('aria-label') || '',
state: node?.dataset.state || null,
alarmClass: node?.classList.contains('alarm') || false,
role: node?.getAttribute('role') || null,
segments: (node?.getAttribute('aria-label') || '').split(', ')
.map((value) => value.trim().toLowerCase()).filter(Boolean),
};
});
out.j2 = j2;
check('j2.alarm_has_accessible_state', /\S/.test(j2.label) && j2.state !== null, true);
check('j2.alarm_not_colour_only', j2.label.split(',').length >= 2, true);
check('j2.alarm_fact_is_not_repeated', new Set(j2.segments).size, j2.segments.length);
check('j2.alarm_is_spoken_once', j2.segments.filter((part) => part === 'alarm').length, 1);
// --- J3. «Сколько в спальне» -------------------------------------------------
// Оракул: числовое значение попадает в ДОСТУПНОЕ ИМЯ, а не только в рисунок.
const j3 = await page.evaluate(() => {
const card = window.__card;
const nodes = [...card.renderRoot.querySelectorAll('[data-hp="device"]')];
const withValue = nodes
.map((node) => ({ id: node.dataset.id, label: node.getAttribute('aria-label') || '' }))
.filter((item) => /\d/.test(item.label));
return { total: nodes.length, withValue: withValue.length, sample: withValue.slice(0, 2) };
});
out.j3 = j3;
check('j3.values_are_readable_by_name', j3.withValue > 0, true);
// --- J4. «Выключить свет с клавиатуры» ---------------------------------------
// Вход: Tab до маркера на плане, затем Enter. Оракул: вызов сервиса, а не
// внутренний флаг карточки.
const tabStops = await (async () => {
let stops = 0;
for (let i = 0; i < 40; i++) {
await page.keyboard.press('Tab');
stops += 1;
const reached = await page.evaluate(() => {
const leaf = (() => {
let node = document.activeElement;
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
return node;
})();
return leaf?.dataset?.hp === 'device' ? leaf.dataset.id : null;
});
if (reached) return { stops, id: reached };
}
return { stops, id: null };
})();
const j4 = await page.evaluate(async () => {
const card = window.__card;
const calls = [];
const hass = card.hass;
const original = hass.callService?.bind(hass);
card.hass = { ...hass, callService: (...args) => { calls.push(`${args[0]}.${args[1]}`); return original?.(...args); } };
await card.updateComplete;
const leaf = (() => {
let node = document.activeElement;
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
return node;
})();
const focusVisible = leaf?.matches?.(':focus-visible') || false;
const ring = leaf ? getComputedStyle(leaf).getPropertyValue('--device-ring-color').trim() : null;
const idle = card.renderRoot.querySelector('[data-hp="device"]:not(:focus-visible)');
const idleRing = idle ? getComputedStyle(idle).getPropertyValue('--device-ring-color').trim() : null;
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
const tipBox = tip?.getBoundingClientRect();
const focusedDevice = card._devices.find((device) => device.id === leaf?.dataset?.id);
const focusTooltip = {
visible: !!tip && !tip.hidden,
title: tip?.querySelector('b')?.textContent?.trim() || '',
text: tip?.textContent?.trim() || '',
expectedTitle: focusedDevice?.name || '',
inViewport: !!tipBox && tipBox.left >= 0 && tipBox.top >= 0
&& tipBox.right <= innerWidth && tipBox.bottom <= innerHeight,
source: card._tip?.source || null,
};
leaf?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 150));
return { calls, focusVisible, ring, idleRing, focusTooltip };
});
out.j4 = { ...j4, ...tabStops };
check('j4.marker_reachable_by_tab', tabStops.id !== null, true);
check('j4.focus_is_visible', j4.focusVisible && j4.ring !== j4.idleRing, true);
check('j4.focus_shows_same_device_tooltip',
j4.focusTooltip.visible && j4.focusTooltip.title === j4.focusTooltip.expectedTitle, true);
check('j4.focus_tooltip_is_anchored_in_viewport',
j4.focusTooltip.source === 'focus' && j4.focusTooltip.inViewport, true);
check('j4.enter_calls_service', j4.calls.length > 0, true);
const j4DualInput = await page.evaluate((focusId) => {
const card = window.__card;
const root = card.renderRoot;
const tip = root.querySelector('[data-hp-live-tip]');
const focusText = tip?.textContent?.trim() || '';
const room = root.querySelector('[data-hp="room"]');
const roomBox = room?.getBoundingClientRect();
room?.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerType: 'mouse',
clientX: roomBox?.left || 0, clientY: roomBox?.top || 0,
}));
const afterRoom = {
source: card._tip?.source || null,
deviceId: card._tip?.deviceId || null,
text: tip?.textContent?.trim() || '',
};
const other = [...root.querySelectorAll('[data-hp="device"]')]
.find((marker) => marker.dataset.id !== focusId);
const otherBox = other?.getBoundingClientRect();
const otherName = card._devices.find((device) => device.id === other?.dataset.id)?.name || '';
other?.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerType: 'mouse',
clientX: (otherBox?.left || 0) + (otherBox?.width || 0) / 2,
clientY: (otherBox?.top || 0) + (otherBox?.height || 0) / 2,
}));
const afterDevice = {
source: card._tip?.source || null,
title: tip?.querySelector('b')?.textContent?.trim() || '',
expectedTitle: otherName,
};
other?.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerType: 'mouse',
clientX: otherBox?.right || 0, clientY: otherBox?.bottom || 0,
}));
const restored = {
source: card._tip?.source || null,
deviceId: card._tip?.deviceId || null,
text: tip?.textContent?.trim() || '',
};
return { focusText, afterRoom, afterDevice, restored };
}, tabStops.id);
out.j4_dual_input = j4DualInput;
check('j4.room_hover_does_not_replace_keyboard_tooltip', j4DualInput.afterRoom,
{ source: 'focus', deviceId: tabStops.id, text: j4DualInput.focusText });
check('j4.other_device_keeps_ordinary_pointer_hover',
j4DualInput.afterDevice.source === 'pointer'
&& j4DualInput.afterDevice.title === j4DualInput.afterDevice.expectedTitle, true);
check('j4.pointer_leave_restores_keyboard_tooltip', j4DualInput.restored,
{ source: 'focus', deviceId: tabStops.id, text: j4DualInput.focusText });
await page.keyboard.press('Tab');
const j4Next = await page.evaluate(() => {
const card = window.__card;
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
const leaf = (() => {
let node = document.activeElement;
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
return node;
})();
return {
visible: !!tip && !tip.hidden,
activeTag: leaf?.tagName || null,
activeHp: leaf?.dataset?.hp || null,
activeId: leaf?.dataset?.id || null,
tipSource: card._tip?.source || null,
tipDevice: card._tip?.deviceId || null,
};
});
out.j4_next = j4Next;
check('j4.tab_moves_focus_tooltip_to_next_device',
j4Next.visible && j4Next.activeHp === 'device' && j4Next.activeId === j4Next.tipDevice
&& j4Next.tipSource === 'focus', true);
const j4Blur = await page.evaluate(() => {
const card = window.__card;
let leaf = document.activeElement;
while (leaf?.shadowRoot?.activeElement) leaf = leaf.shadowRoot.activeElement;
leaf?.blur?.();
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
return { hidden: !tip || tip.hidden, stateCleared: card._tip === null };
});
out.j4_blur = j4Blur;
check('j4.blur_hides_focus_tooltip', j4Blur.hidden && j4Blur.stateCleared, true);
const j4HoverParity = await page.evaluate((deviceId) => {
const card = window.__card;
const marker = card.renderRoot.querySelector(`[data-hp="device"][data-id="${deviceId}"]`);
const rect = marker?.getBoundingClientRect();
marker?.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerType: 'mouse',
clientX: rect?.left || 0, clientY: rect?.top || 0,
}));
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
const hoverText = tip?.textContent?.trim() || '';
card._showTip(new PointerEvent('pointermove', {
pointerType: 'mouse', clientX: innerWidth, clientY: innerHeight,
}), 'Viewport edge', 'clamp probe');
const edgeBox = tip?.getBoundingClientRect();
const edgeInViewport = !!edgeBox && edgeBox.left >= 0 && edgeBox.top >= 0
&& edgeBox.right <= innerWidth && edgeBox.bottom <= innerHeight;
card._clearTransientHover();
return { hoverText, edgeInViewport };
}, tabStops.id);
out.j4_hover_parity = j4HoverParity;
check('j4.focus_and_mouse_tooltip_content_match',
j4HoverParity.hoverText, j4.focusTooltip.text);
check('j4.tooltip_clamps_at_viewport_edge', j4HoverParity.edgeInViewport, true);
// --- J5. «Переключить этаж» --------------------------------------------------
const j5 = await page.evaluate(async () => {
const card = window.__card;
const root = card.renderRoot;
const tabs = [...root.querySelectorAll('[data-hp="space-tab"]')];
const before = card._space;
const other = tabs.find((tab) => tab.dataset.id !== before);
const beforeRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
other.click();
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 250));
const afterRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
const activeTab = root.querySelector('[data-hp="space-tab"].active');
const navigation = root.querySelector('nav.tabs');
const currentTabs = [...root.querySelectorAll('[data-hp="space-tab"][aria-current]')];
const add = root.querySelector('[data-hp="space-add"]');
return {
tabs: tabs.length, before, after: card._space,
roomsChanged: beforeRooms !== afterRooms,
activeTabIsCurrent: activeTab?.dataset.id === card._space,
// Состояние вкладки для программы: есть ли он вообще, кроме класса.
ariaCurrent: activeTab?.getAttribute('aria-current'),
ariaPressed: activeTab?.getAttribute('aria-pressed'),
ariaSelected: activeTab?.getAttribute('aria-selected'),
navigationName: navigation?.getAttribute('aria-label') || '',
currentCount: currentTabs.length,
inactiveHaveNoCurrent: [...root.querySelectorAll('[data-hp="space-tab"]')]
.filter((tab) => tab !== activeTab).every((tab) => !tab.hasAttribute('aria-current')),
addHasNoCurrent: !add?.hasAttribute('aria-current'),
hasTablistRoles: !!root.querySelector('[role="tablist"], [role="tab"]'),
};
});
out.j5 = j5;
check('j5.floor_switched', j5.after !== j5.before, true);
check('j5.plan_rerendered', j5.roomsChanged, true);
check('j5.active_tab_matches_space', j5.activeTabIsCurrent, true);
check('j5.spaces_are_named_navigation', /\S/.test(j5.navigationName), true);
check('j5.only_current_space_is_programmatic',
j5.ariaCurrent === 'page' && j5.currentCount === 1 && j5.inactiveHaveNoCurrent
&& j5.addHasNoCurrent, true);
check('j5.native_button_navigation_is_kept', j5.hasTablistRoles, false);
const j5Keyboard = {};
for (const [key, expected] of [['Enter', j5.before], [' ', j5.after]]) {
const before = await page.evaluate(() => window.__card._space);
await page.evaluate((id) => {
window.__card.renderRoot.querySelector(`[data-hp="space-tab"][data-id="${id}"]`)?.focus();
}, expected);
await page.keyboard.press(key === ' ' ? 'Space' : key);
await settle();
const after = await page.evaluate(() => window.__card._space);
j5Keyboard[key === ' ' ? 'space' : 'enter'] = { before, after, expected };
}
out.j5_keyboard = j5Keyboard;
check('j5.enter_switches_space', j5Keyboard.enter.after, j5Keyboard.enter.expected);
check('j5.space_switches_space', j5Keyboard.space.after, j5Keyboard.space.expected);
// --- J6. «Вернулся после обновления» ----------------------------------------
const j6 = await page.evaluate(async () => {
const card = window.__card;
const chosen = card._space;
card._serverCfg = structuredClone(card._serverCfg);
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 200));
return {
chosen, after: card._space,
rooms: card.renderRoot.querySelectorAll('[data-hp="room"]').length,
devices: card.renderRoot.querySelectorAll('[data-hp="device"]').length,
};
});
out.j6 = j6;
check('j6.space_kept', j6.after === j6.chosen, true);
check('j6.plan_redrawn', j6.rooms > 0 && j6.devices > 0, true);
// --- J7. Цель нажатия при плотной расстановке --------------------------------
// Вход: пять маркеров в колонке с шагом 0,0333 нормированной единицы — самая
// плотная расстановка, найденная на реальной установке владельца.
// Оракул: в центре ВИДИМОГО маркера нажатие достаётся ему же.
const dense = await page.evaluate(async () => {
const card = window.__card;
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
const room = space.rooms.find((candidate) => candidate.id && candidate.area) || space.rooms[0];
const ids = ['dense1', 'dense2', 'dense3', 'dense4', 'dense5'];
card._serverCfg.markers = [...(card._serverCfg.markers || []), ...ids.map((id, index) => ({
id, name: `Dense ${index + 1}`, binding: 'virtual', is_light: true, tap_action: 'info',
space: space.id, area: room.area, room_id: room.id,
}))];
const layout = { ...card._layout };
ids.forEach((id, index) => { layout[id] = { s: space.id, x: 0.5, y: 0.3 + index * 0.0333 }; });
card._layout = layout;
card._cfgEpoch++;
card._regSignature = '';
card._maybeRebuildDevices();
card.requestUpdate();
await card.updateComplete;
return { ids };
});
const ownerOfCentre = async (id) => {
const centre = await settledBox(id);
if (!centre) return null;
return page.evaluate(([cx, cy]) => {
let node = document.elementFromPoint(cx, cy);
while (node?.shadowRoot?.elementFromPoint) {
const deeper = node.shadowRoot.elementFromPoint(cx, cy);
if (!deeper || deeper === node) break;
node = deeper;
}
return node?.closest?.('[data-hp="device"]')?.dataset?.id || null;
}, [centre[0], centre[1]]);
};
const semanticOwnerOfCentre = async (id) => {
const centre = await settledBox(id);
if (!centre) return null;
return page.evaluate(([cx, cy]) => window.__card._deviceHitOwnerAt(cx, cy)?.id || null,
[centre[0], centre[1]]);
};
const clickAndReadInfoOwner = async (centre) => {
await page.mouse.click(centre[0], centre[1]);
await page.waitForTimeout(100);
return page.evaluate(async () => {
const card = window.__card;
const id = card._infoCard?.id || null;
card._infoCard = null;
card.requestUpdate();
await card.updateComplete;
return id;
});
};
const tablet = {
size: (await settledBox('dense1'))?.[2] ?? null, owners: [], semanticOwners: [],
};
for (const id of dense.ids) {
tablet.owners.push(await ownerOfCentre(id));
tablet.semanticOwners.push(await semanticOwnerOfCentre(id));
}
const tabletCentre = await settledBox('dense1');
tablet.clickWentTo = await clickAndReadInfoOwner(tabletCentre);
out.j7_tablet = tablet;
// Настенный планшет: центр видимого маркера принадлежит ему. Это то свойство,
// которое правка плотных целей обязана сохранить.
check('j7.tablet_centre_belongs_to_its_marker',
tablet.owners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.tablet_semantic_owner_is_its_marker',
tablet.semanticOwners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.tablet_click_reaches_its_marker', tablet.clickWentTo, 'dense1');
// Узкая колонка телефона — исходная регрессия #564. И native hit owner, и
// семантический владелец действия обязаны совпасть с видимым маркером.
await setWidth(390);
const phone = {
size: (await settledBox('dense1'))?.[2] ?? null, owners: [], semanticOwners: [],
};
for (const id of dense.ids) {
phone.owners.push(await ownerOfCentre(id));
phone.semanticOwners.push(await semanticOwnerOfCentre(id));
}
const phoneCentre = await settledBox('dense1');
phone.clickWentTo = await clickAndReadInfoOwner(phoneCentre);
phone.latchedOwner = await page.evaluate(async () => {
const card = window.__card;
const first = card.renderRoot.querySelector('[data-hp="device"][data-id="dense1"]');
const second = card.renderRoot.querySelector('[data-hp="device"][data-id="dense2"]');
const a = first.getBoundingClientRect();
const b = second.getBoundingClientRect();
const pointerId = 564;
const event = (type, rect, buttons) => new PointerEvent(type, {
pointerId, pointerType: 'touch', buttons, bubbles: true, cancelable: true,
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
});
const d1 = card._devices.find((device) => device.id === 'dense1');
const d2 = card._devices.find((device) => device.id === 'dense2');
card._pointerDown(event('pointerdown', a, 1), d1);
const moved = card._pointerMove(event('pointermove', b, 1), d2)?.id || null;
card._pointerUp(event('pointerup', b, 0), d2);
card._clickDevice(event('click', b, 0), d2);
await card.updateComplete;
const clicked = card._infoCard?.id || null;
card._infoCard = null;
card.requestUpdate();
await card.updateComplete;
return { moved, clicked };
});
out.j7_phone = phone;
check('j7.phone_centre_belongs_to_its_marker',
phone.owners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.phone_semantic_owner_is_its_marker',
phone.semanticOwners.every((owner, index) => owner === dense.ids[index]), true);
check('j7.phone_click_reaches_its_marker', phone.clickWentTo, 'dense1');
check('j7.pointer_owner_is_latched_through_terminal_click',
phone.latchedOwner, { moved: 'dense1', clicked: 'dense1' });
await finish(browser, out);
+18 -40
View File
@@ -1,4 +1,4 @@
// #122/#160 Stage 3: alpha lifecycle, structural composition and flat editor boundary.
// #122/#160/#570 Stage 4: alpha lifecycle, visual handoff and flat editor boundary.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
@@ -125,7 +125,7 @@ const out = await page.evaluate(async () => {
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
result.stage3RevisionAdvertised = stage?.getAttribute('data-hp-iso-stage') === '3';
result.stage4RevisionAdvertised = stage?.getAttribute('data-hp-iso-stage') === '4';
result.sharedProjectionSnapshot = [
'.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg', '.iso-overlays-svg',
]
@@ -135,52 +135,49 @@ const out = await page.evaluate(async () => {
const requiredDefs = [
'hp-iso-wall-side', 'hp-iso-wall-top', 'hp-iso-wall-texture',
'hp-iso-floor-texture', 'hp-iso-ambient-shadow', 'hp-iso-contact-shadow',
'hp-iso-leaf-shadow', 'hp-iso-overlay-ground',
'hp-iso-leaf-shadow',
];
result.stage3DefinitionsBounded = materialDefs.length >= requiredDefs.length
result.stage4DefinitionsBounded = materialDefs.length >= requiredDefs.length
&& materialDefs.length <= 16
&& new Set(materialDefs.map((node) => node.id)).size === materialDefs.length
&& requiredDefs.every((id) => materialDefs.some((node) => node.id === id));
const shadowNodes = [...root().querySelectorAll(
'.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow, .iso-overlay-ground',
'.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow',
)];
const fixedLightTransform = card._isoSceneRuntime.isoFixedLightTransform(card._cellCm);
result.fixedLightVectorShared = shadowNodes.some((node) => node.matches('.iso-overlay-ground'))
&& ['iso-ambient-shadow', 'iso-contact-shadow', 'iso-leaf-shadow', 'iso-overlay-ground']
result.fixedLightVectorShared = ['iso-ambient-shadow', 'iso-contact-shadow', 'iso-leaf-shadow']
.every((name) => shadowNodes.some((node) => node.classList.contains(name)))
&& shadowNodes.every((node) => node.getAttribute('transform') === fixedLightTransform);
const hasOpeningSurface = (id, surface, material) => !!root().querySelector(
`[data-hp="iso-openings"] [data-id="${id}"][data-surface="${surface}"].iso-material-${material}`,
);
result.stage3OpeningMaterials = [
result.stage4OpeningMaterials = [
['iso-centred-door', 'jamb-reveal', 'reveal'],
['iso-centred-door', 'leaf-front', 'matte-leaf'],
['iso-centred-door', 'leaf-back', 'matte-leaf'],
['iso-centred-door', 'leaf-edge', 'matte-leaf'],
['iso-centred-door', 'leaf-top', 'matte-leaf'],
['iso-flipped-window', 'window-insert', 'light-window'],
['iso-flipped-window', 'window-frame-side', 'light-frame'],
['iso-flipped-window', 'window-frame-top', 'light-frame'],
['iso-flipped-window', 'window-sill', 'light-sill'],
['iso-flipped-window', 'window-sash-side', 'light-frame'],
['iso-flipped-window', 'window-sash-bottom', 'light-frame'],
['iso-flipped-window', 'window-sash-top', 'light-frame'],
['iso-flipped-window', 'window-glass', 'glass-side'],
['iso-flipped-window', 'window-glass-top', 'glass-top'],
['iso-centred-gate', 'jamb-reveal', 'reveal'],
['iso-centred-gate', 'leaf-front', 'matte-leaf'],
].every(([id, surface, material]) => hasOpeningSurface(id, surface, material))
&& !root().querySelector(
'[data-hp="iso-openings"] [data-surface*="glass"],'
+ '[data-hp="iso-openings"] .iso-material-dark-glass',
);
&& !root().querySelector('[data-hp="iso-openings"] .iso-material-dark-glass');
const raisedRoot = (node, kind) => node?.getAttribute('data-hp-iso-overlay-kind') === kind
&& node.getAttribute('data-hp-iso-raised') === 'true'
&& node.getAttribute('data-hp-iso-floor')
&& node.getAttribute('data-hp-iso-visual')
&& node.getAttribute('data-hp-iso-floor') !== node.getAttribute('data-hp-iso-visual');
result.stage3RaisesExactInteractiveRoots = raisedRoot(device, 'device')
result.stage4UsesExactLowInteractiveRoots = raisedRoot(device, 'device')
&& raisedRoot(roomLabel, 'room-label')
&& raisedRoot(openingLock, 'opening-lock')
&& ['device', 'room-label', 'opening-lock'].every((kind) => root().querySelector(
`[data-hp="iso-raised-overlays"] .iso-overlay-tether`
+ `[data-hp-iso-overlay-kind="${kind}"]`,
));
&& !root().querySelector('.iso-overlay-tether, .iso-overlay-ground, #hp-iso-overlay-ground');
result.raisedFootprintsStayInvisible = !root().querySelector(
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
);
@@ -192,31 +189,12 @@ const out = await page.evaluate(async () => {
const raisedNodes = [...root().querySelectorAll('[data-hp-iso-raised="true"]')]
.filter((node) => node.closest('.devlayer'));
const raisedGeometryMatches = (node) => {
const kind = node.getAttribute('data-hp-iso-overlay-kind');
const id = node.getAttribute('data-id');
const suffix = id ? `[data-id="${CSS.escape(id)}"]` : '';
const visual = (node.getAttribute('data-hp-iso-visual') || '').split(',').map(Number);
const floorPoint = (node.getAttribute('data-hp-iso-floor') || '').split(',').map(Number);
if (visual.length !== 2 || floorPoint.length !== 2) return false;
const expected = sceneToClient(visual), actual = center(node);
const ground = root().querySelector(
`[data-hp="iso-overlay-grounds"] [data-hp-iso-overlay-kind="${kind}"]${suffix}`,
) || root().querySelector(
`[data-hp="iso-overlay-grounds"] [data-hp-iso-overlay-kind="${kind}"]`,
);
const tether = root().querySelector(
`[data-hp="iso-raised-overlays"] .iso-overlay-tether[data-hp-iso-overlay-kind="${kind}"]${suffix}`,
) || root().querySelector(
`[data-hp="iso-raised-overlays"] .iso-overlay-tether[data-hp-iso-overlay-kind="${kind}"]`,
);
return Math.hypot(actual[0] - expected[0], actual[1] - expected[1]) <= 1
&& !!ground && Math.hypot(Number(ground.getAttribute('cx')) - floorPoint[0],
Number(ground.getAttribute('cy')) - floorPoint[1]) <= 1e-6
&& !!tether
&& Math.hypot(Number(tether.getAttribute('x1')) - floorPoint[0],
Number(tether.getAttribute('y1')) - floorPoint[1]) <= 1e-6
&& Math.hypot(Number(tether.getAttribute('x2')) - visual[0],
Number(tether.getAttribute('y2')) - visual[1]) <= 1e-6;
&& Math.hypot(floorPoint[0] - visual[0], floorPoint[1] - visual[1]) > 0;
};
result.globalFitContainsRaisedFootprints = raisedNodes.length >= 3
&& raisedNodes.every((node) => inside(root().querySelector('.stage'), node));
@@ -384,10 +362,10 @@ const out = await page.evaluate(async () => {
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top')
&& !!root().querySelector('[data-hp="iso-openings"] .iso-opening-panel')
&& !root().querySelector('.iso-overlay-plate, .iso-overlay-plate-texture')
&& !!root().querySelector('[data-hp="iso-raised-overlays"] .iso-overlay-tether')
&& !root().querySelector('.iso-overlay-tether, .iso-overlay-ground, #hp-iso-overlay-ground')
&& !root().querySelector('[data-hp-iso-material-def]')
&& !root().querySelector('.iso-ambient-shadow, .iso-contact-shadow,'
+ ' .iso-leaf-shadow, .iso-overlay-ground');
+ ' .iso-leaf-shadow');
CSS.supports = nativeCssSupports;
card.requestUpdate();
await card.updateComplete;
+19 -20
View File
@@ -1,4 +1,4 @@
// #122/#160 Stage 3: live-floor preservation, raised overlays, openings and touch paths.
// #122/#160/#570 Stage 4: live-floor preservation, low overlays, openings and touch paths.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
@@ -56,7 +56,7 @@ const out = await page.evaluate(async () => {
},
});
// Put one real interactive device almost on the shared wall so this smoke
// always owns a deterministic non-zero nudge/tether witness.
// always owns a deterministic non-zero nudge witness.
original._layout.d_light1 = { ...original._layout.d_light1, s: 'f1', x: 0.548, y: 0.22 };
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
@@ -188,12 +188,10 @@ const out = await page.evaluate(async () => {
];
const overlaySvg = root(original).querySelector('.iso-overlays-svg');
const wallSvg = root(original).querySelector('.iso-walls-svg');
const overlayGround = root(original).querySelector('[data-hp="iso-overlay-grounds"]');
const overlayGroundFilter = root(original).querySelector('#hp-iso-overlay-ground');
const doorResolved = original._openingsR.find((opening) => opening.id === 'iso-door');
const isoFloorMatrix = (root(original).querySelector('.iso-floor-scene')
?.getAttribute('transform')?.match(/-?[\d.e+]+/gi) || []).map(Number);
const wallHeight = 64 * (5 / original._cellCm);
const wallHeight = 84 * (5 / original._cellCm);
const openingTopPoint = doorResolved && isoFloorMatrix.length === 6
? new DOMPoint(
isoFloorMatrix[0] * doorResolved.rx + isoFloorMatrix[2] * doorResolved.ry + isoFloorMatrix[4],
@@ -206,7 +204,7 @@ const out = await page.evaluate(async () => {
&& ![...root(original).querySelectorAll('.iso-wall-top')]
.some((path) => path.isPointInFill(openingTopPoint));
const result = {
isoOnTouch: root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '3'
isoOnTouch: root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
&& !!root(original).querySelector('[data-hp="iso-walls"]'),
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
=== JSON.stringify(before)
@@ -225,10 +223,11 @@ const out = await page.evaluate(async () => {
&& ordered.slice(1).every((node, index) => Boolean(
ordered[index].compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING
)),
overlayGroundLivesAboveWallsWithDefs: overlayGround?.closest('svg') === overlaySvg
&& overlayGroundFilter?.closest('svg') === overlaySvg
&& root(original).querySelectorAll('#hp-iso-overlay-ground').length === 1
&& Boolean(wallSvg?.compareDocumentPosition(overlayGround) & Node.DOCUMENT_POSITION_FOLLOWING),
overlaysStayAboveWallsWithoutDebugGrounds: !!overlaySvg && !!wallSvg
&& Boolean(wallSvg.compareDocumentPosition(overlaySvg) & Node.DOCUMENT_POSITION_FOLLOWING)
&& !root(original).querySelector(
'.iso-overlay-ground, .iso-overlay-tether, #hp-iso-overlay-ground',
),
haColorUpdatePainted: [...root(original).querySelectorAll('radialGradient stop')]
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
@@ -295,10 +294,12 @@ const out = await page.evaluate(async () => {
const noBordersRoom = root(original).querySelector('[data-hp="room-label"]');
const noBordersLock = root(original).querySelector('.oplock');
result.noBordersUsesTrueAffineFloor = original._effectiveProjection() === 'iso'
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '3'
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
&& root(original).querySelector('.plan-svg')?.getAttribute('data-hp-live-viewbox') === 'camera'
&& floorMatrix.length === 6 && floorMatrix.every(Number.isFinite)
&& (Math.abs(floorMatrix[1]) > 1e-6 || Math.abs(floorMatrix[2]) > 1e-6)
&& Math.abs(floorMatrix[0] - 1) < 1e-6
&& Math.abs(floorMatrix[1]) < 1e-6 && Math.abs(floorMatrix[2]) < 1e-6
&& Math.abs(floorMatrix[3] - Math.cos(20 * Math.PI / 180)) < 1e-6
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3
&& [noBordersDevice, noBordersRoom, noBordersLock].every((node) => node
&& !node.hasAttribute('data-hp-iso-raised'));
@@ -329,9 +330,9 @@ const out = await page.evaluate(async () => {
original._cfgEpoch++;
original.requestUpdate();
await original.updateComplete;
result.visibleBordersRestoreStage3 = !!root(original).querySelector('[data-hp="iso-openings"]')
&& !!root(original).querySelector('[data-hp="iso-raised-overlays"]')
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '3'
result.visibleBordersRestoreStage4 = !!root(original).querySelector('[data-hp="iso-openings"]')
&& !!root(original).querySelector('[data-hp-iso-raised="true"]')
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
&& original._isoGeometryCache.size === 1;
original._hoverRoom = null;
@@ -350,7 +351,7 @@ const out = await page.evaluate(async () => {
result.runtimeNudgeNeverPersists = JSON.stringify(original._layout) === floorLayoutBeforeLive
&& forbiddenWrites.length === 0 && storageWrites.length === 0
&& !!root(original).querySelector('[data-hp-iso-nudged="true"]')
&& !!root(original).querySelector('.iso-overlay-tether');
&& !root(original).querySelector('.iso-overlay-tether, .iso-overlay-ground');
Storage.prototype.setItem = nativeStorageSet;
const fallbackCalls = [];
@@ -365,9 +366,6 @@ const out = await page.evaluate(async () => {
await original.updateComplete;
const fallbackNudged = root(original).querySelector('[data-hp-iso-nudged="true"][data-id]');
const fallbackId = fallbackNudged?.getAttribute('data-id');
const fallbackTether = fallbackId && root(original).querySelector(
`.iso-overlay-tether[data-id="${CSS.escape(fallbackId)}"]`,
);
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
await frame();
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
@@ -377,7 +375,8 @@ const out = await page.evaluate(async () => {
result.unsupportedDecorationKeepsFootprintInvisible = !root(original).querySelector(
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
);
result.unsupportedDecorationKeepsTether = !!fallbackTether;
result.unsupportedDecorationAddsNoDebugCue = !!fallbackId
&& !root(original).querySelector('.iso-overlay-tether, .iso-overlay-ground');
result.unsupportedDecorationKeepsActions = fallbackCalls.length === 1;
CSS.supports = supports;
original.requestUpdate();
+37
View File
@@ -279,9 +279,46 @@ if (handle) {
}
}
// #330 AC4 / #550: the production cache is keyed by the config object because
// physical commits intentionally update that object in place. Prove that a
// changed geometry fingerprint invalidates an entry even when object identity
// is unchanged; the gesture above normally receives a fresh object after the
// write and therefore cannot distinguish this case on its own.
resize.resizeBaselineFingerprintRejectsStaleObject = await page.evaluate(() => {
const card = window.__card;
const runtime = card._editorRuntime;
const previous = structuredClone(card._serverCfg);
const space = previous?.spaces?.find((entry) => entry.id === card._space);
if (!runtime || !space || !space.rooms?.length) return false;
const firstRoom = space.rooms[0];
const point = Array.isArray(firstRoom.poly) ? firstRoom.poly[0] : null;
if (!Array.isArray(point)) return false;
const original = card._junctionLimitViolations;
let calls = 0;
card._junctionLimitViolations = (...args) => {
if (args[0] === previous) calls += 1;
return original(...args);
};
try {
runtime._junctionLimitsIntroduced(structuredClone(previous), previous, space.id);
if (!runtime._junctionBaselineCache.get(previous)) return false;
point[0] += 0.001;
runtime._junctionLimitsIntroduced(structuredClone(previous), previous, space.id);
return calls === 2;
} finally {
card._junctionLimitViolations = original;
}
});
checkAll({ ...out, ...resize }, {
resizeGapBefore: 10,
resizeStoppedAtLastAllowed: 6,
resizeRefusalOnce: 1,
resizeHandleFound: true,
resizeRefusalNamesRule: true,
resizeBaselineComputedOncePerGesture: true,
resizeSecondHandleFound: true,
resizeBaselineRecomputedAfterCommit: true,
resizeBaselineFingerprintRejectsStaleObject: true,
});
await finish(browser);
+40 -5
View File
@@ -15,10 +15,12 @@ const result = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => requestAnimationFrame(
() => requestAnimationFrame(resolve),
));
const until = async (predicate, timeout = 6000) => {
const until = async (predicate, timeout = 6000, label = '') => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('linked-light smoke timed out');
if (performance.now() - started > timeout) {
throw new Error(`linked-light smoke timed out${label ? `: ${label}` : ''}`);
}
await wait(25);
}
};
@@ -162,8 +164,24 @@ const result = await page.evaluate(async () => {
})}`, { cause: error });
}
};
let clickPointerId = 17300;
const click = async (id) => {
node(id).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
const target = node(id);
const rect = target.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
const pointerId = ++clickPointerId;
target.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerId, pointerType: 'mouse',
clientX, clientY, button: 0, buttons: 1,
}));
target.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerId, pointerType: 'mouse',
clientX, clientY, button: 0, buttons: 0,
}));
target.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, clientX, clientY, button: 0,
}));
await wait(0);
};
const targetsOf = (call) => {
@@ -283,11 +301,28 @@ const result = await page.evaluate(async () => {
card._regSignature = '';
card._maybeRebuildDevices();
await card.updateComplete;
await until(() => !isOn(sourceId));
await until(() => !isOn(sourceId), 6000, 'unlink did not restore manual off');
const unlinkRestoresManualOff = !isOn(sourceId) && virtual.off.includes(sourceId);
const beforeManualService = serviceCalls.length;
await click(sourceId);
await until(() => isOn(sourceId));
try {
await until(() => isOn(sourceId), 6000, 'manual virtual toggle did not turn on');
} catch (error) {
throw new Error(`manual virtual toggle did not settle: ${JSON.stringify({
operationalToggleCalls,
serviceCalls: serviceCalls.length,
beforeManualService,
off: virtual.off,
suppressClick: card._suppressClick,
holdFired: card._holdFired,
deviceDrag: card._deviceDrag,
sourceAction: item(sourceId)?.tapAction,
sourceBinding: item(sourceId)?.binding,
sourceState: card._stateClass(item(sourceId)),
sourceIntent: card._toggleIntent(item(sourceId)),
sourceConnected: node(sourceId)?.isConnected,
})}`, { cause: error });
}
const manualToggleRestored = operationalToggleCalls === 1
&& serviceCalls.length === beforeManualService && !virtual.off.includes(sourceId);
+481
View File
@@ -0,0 +1,481 @@
// #544: `setLayerProjection` must expose scene pixels beyond the old SVG
// viewport while the compositor transform is live. The outer `.stage` still
// clips the card. DOM strings and a screenshot after pointerup cannot prove
// this contract, so the oracle compares held and forced-settled pixels for the
// same camera target and counts scene pixels replaced by stage background.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
{ width: 1100, height: 760 }, 1, [], { hasTouch: true },
);
const checks = {};
const diagnostics = {};
const frame = () => page.evaluate(() => new Promise((done) => requestAnimationFrame(done)));
const settle = async () => {
await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
};
const stage = () => page.locator('houseplan-card').locator('.stage');
const resetCamera = async () => {
await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
const stage = card.renderRoot.querySelector('.stage');
const width = 300;
card._view = {
x: 350,
y: 350,
w: width,
h: width * stage.clientHeight / stage.clientWidth,
};
card._zoom = 3;
card.requestUpdate();
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
};
const liveState = () => page.evaluate(() => {
const card = document.querySelector('houseplan-card');
const root = card.renderRoot;
const scenes = [...root.querySelectorAll('[data-hp-live-viewbox]')];
const centre = (node) => {
const rect = node?.getBoundingClientRect();
return rect ? [rect.left + rect.width / 2, rect.top + rect.height / 2] : null;
};
const stage = root.querySelector('.stage');
return {
view: structuredClone(card._view),
transformed: scenes.filter((node) => node.style.transform).length,
sceneCount: scenes.length,
allSceneOverflowOpen: scenes.length > 0
&& scenes.every((node) => node.style.overflow === 'visible'),
allScenePromoted: scenes.length > 0 && scenes.every((node) => node.style.transform
&& node.style.transformOrigin && node.style.willChange === 'transform'
&& node.style.overflow === 'visible'),
allTemporaryStylesCleared: scenes.every((node) => !node.style.overflow
&& !node.style.transform && !node.style.transformOrigin && !node.style.willChange),
marker: centre(root.querySelector('[data-hp="device"]')),
room: centre(root.querySelector('[data-hp="room"]')),
effects: {
dayCycle: !!root.querySelector('.hp-day-cycle-env .hp-day-cycle-bg.active'),
glowBases: root.querySelectorAll('.glow-base-layer .glow-base').length,
glowPools: root.querySelectorAll('.glowlayer circle, .glow-pool').length,
roomFills: root.querySelectorAll('.room.filled').length,
hatchedWalls: root.querySelectorAll('.wallbody:not(.solid)[fill^="url("]').length,
customBackground: (stage?.getAttribute('style') || '').includes('#123456'),
},
};
});
const backgroundRgb = async () => page.evaluate(() => {
const stage = document.querySelector('houseplan-card').renderRoot.querySelector('.stage');
return (getComputedStyle(stage).backgroundColor.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
});
const compareCoverage = async (actual, reference, edge, background) => page.evaluate(async (input) => {
const decode = async (base64) => {
const image = new Image();
image.src = `data:image/png;base64,${base64}`;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
return {
width: image.width,
height: image.height,
data: context.getImageData(0, 0, image.width, image.height).data,
};
};
const [left, right] = await Promise.all([decode(input.actual), decode(input.reference)]);
if (left.width !== right.width || left.height !== right.height) {
return { sameSize: false, missing: Number.POSITIVE_INFINITY, referenceScenePixels: 0 };
}
const nearBackground = (data, index) => input.background.every(
(channel, offset) => Math.abs(data[index + offset] - channel) <= 5,
);
const inBand = (x, y) => {
if (input.edge === 'left') return x < left.width * 0.14;
if (input.edge === 'right') return x >= left.width * 0.86;
if (input.edge === 'top') return y < left.height * 0.14;
if (input.edge === 'bottom') return y >= left.height * 0.86;
return true;
};
let missing = 0;
let referenceScenePixels = 0;
for (let y = 0; y < left.height; y += 1) {
for (let x = 0; x < left.width; x += 1) {
if (!inBand(x, y)) continue;
const index = (y * left.width + x) * 4;
const referenceIsScene = !nearBackground(right.data, index);
if (!referenceIsScene) continue;
referenceScenePixels += 1;
if (nearBackground(left.data, index)) missing += 1;
}
}
return { sameSize: true, missing, referenceScenePixels };
}, {
actual: actual.toString('base64'),
reference: reference.toString('base64'),
edge,
background,
});
const pointerCentre = async () => {
const rect = await stage().boundingBox();
if (!rect) throw new Error('stage has no bounding box');
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
};
const preparePointerCapture = () => page.evaluate(() => {
const stage = document.querySelector('houseplan-card').renderRoot.querySelector('.stage');
stage.setPointerCapture = () => {};
stage.releasePointerCapture = () => {};
});
const dispatchTouch = (type, id, x, y, buttons = type === 'pointerup' ? 0 : 1) => page.evaluate(
({ type, id, x, y, buttons }) => {
const stage = document.querySelector('houseplan-card').renderRoot.querySelector('.stage');
stage.dispatchEvent(new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerId: id,
isPrimary: id % 2 === 1,
pointerType: 'touch',
button: type === 'pointerdown' ? 0 : -1,
buttons,
clientX: x,
clientY: y,
}));
},
{ type, id, x, y, buttons },
);
const configureEffectMatrix = ({ dayCycle, glow }) => page.evaluate(async (variant) => {
const card = document.querySelector('houseplan-card');
const config = structuredClone(card._serverCfg);
const space = config.spaces.find((item) => item.id === card._space) || config.spaces[0];
config.settings = {
...(config.settings || {}),
bg_color: '#123456',
bg_mode: variant.dayCycle ? 'daynight' : 'static',
};
space.settings = {
...(space.settings || {}),
bg_color: '#123456',
bg_mode: variant.dayCycle ? 'daynight' : 'static',
fill_mode: 'custom',
custom_fill: { c: '#d02020', a: 1 },
glow_enabled: variant.glow,
show_borders: true,
};
card._serverCfg = config;
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((done) => setTimeout(done, 250));
}, { dayCycle, glow });
const runPan = async ({ name, kind, dx, dy, edge, parity = false, effects = null }) => {
await resetCamera();
await preparePointerCapture();
const origin = await pointerCentre();
const before = parity ? await liveState() : null;
if (kind === 'mouse') {
await page.mouse.move(origin.x, origin.y);
await page.mouse.down();
await page.mouse.move(origin.x + dx * 0.18, origin.y + dy * 0.18);
await frame();
await page.mouse.move(origin.x + dx, origin.y + dy);
} else {
await dispatchTouch('pointerdown', 701, origin.x, origin.y);
await dispatchTouch('pointermove', 701, origin.x + dx * 0.18, origin.y + dy * 0.18);
await frame();
await dispatchTouch('pointermove', 701, origin.x + dx, origin.y + dy);
}
await frame();
const immediateState = await liveState();
const immediate = await stage().screenshot({ animations: 'disabled' });
await page.waitForTimeout(160);
const heldState = await liveState();
const held = await stage().screenshot({ animations: 'disabled' });
if (kind === 'mouse') await page.mouse.up();
else await dispatchTouch('pointerup', 701, origin.x + dx, origin.y + dy, 0);
await settle();
const settledState = await liveState();
const reference = await stage().screenshot({ animations: 'disabled' });
const background = await backgroundRgb();
const immediateCoverage = await compareCoverage(immediate, reference, edge, background);
const heldCoverage = await compareCoverage(held, reference, edge, background);
const parityPx = parity && before?.marker && before?.room
&& immediateState.marker && immediateState.room
? Math.max(
Math.abs((immediateState.marker[0] - before.marker[0])
- (immediateState.room[0] - before.room[0])),
Math.abs((immediateState.marker[1] - before.marker[1])
- (immediateState.room[1] - before.room[1])),
)
: null;
diagnostics[name] = {
immediateCoverage,
heldCoverage,
transformed: immediateState.transformed,
heldTransformed: heldState.transformed,
sceneCount: immediateState.sceneCount,
overflowImmediate: immediateState.allSceneOverflowOpen,
overflowHeld: heldState.allSceneOverflowOpen,
promotedImmediate: immediateState.allScenePromoted,
promotedHeld: heldState.allScenePromoted,
targetStableWhileHeld: JSON.stringify(immediateState.view) === JSON.stringify(heldState.view),
temporaryStylesCleared: settledState.allTemporaryStylesCleared,
effectsImmediate: immediateState.effects,
effectsHeld: heldState.effects,
parityPx,
};
checks[`${name}ImmediateCoverage`] = immediateCoverage.sameSize
&& immediateCoverage.referenceScenePixels > 500 && immediateCoverage.missing === 0;
checks[`${name}HeldCoverage`] = heldCoverage.sameSize
&& heldCoverage.referenceScenePixels > 500 && heldCoverage.missing === 0;
checks[`${name}UsesTemporaryOverflow`] = immediateState.allScenePromoted
&& heldState.allScenePromoted
&& settledState.allTemporaryStylesCleared;
checks[`${name}TargetStableWhileHeld`] = diagnostics[name].targetStableWhileHeld;
if (effects) {
const expected = (sample) => sample.roomFills > 0 && sample.hatchedWalls > 0
&& sample.customBackground
&& sample.dayCycle === effects.dayCycle
&& (effects.glow
? sample.glowPools > 0
: sample.glowPools === 0);
checks[`${name}EffectsVisible`] = expected(immediateState.effects)
&& expected(heldState.effects);
}
if (parity) checks[`${name}MarkerParity`] = parityPx !== null && parityPx <= 1;
};
const runTouchZoomOut = async ({ name }) => {
await resetCamera();
await preparePointerCapture();
const origin = await pointerCentre();
const beforeZoom = await page.evaluate(() => document.querySelector('houseplan-card')._zoom);
await dispatchTouch('pointerdown', 711, origin.x - 80, origin.y);
await dispatchTouch('pointerdown', 712, origin.x + 80, origin.y);
await dispatchTouch('pointermove', 711, origin.x - 76, origin.y);
await dispatchTouch('pointermove', 712, origin.x + 76, origin.y);
await frame();
await dispatchTouch('pointermove', 711, origin.x - 68, origin.y);
await dispatchTouch('pointermove', 712, origin.x + 68, origin.y);
await frame();
const immediateState = await liveState();
const immediate = await stage().screenshot({ animations: 'disabled' });
await page.waitForTimeout(160);
const heldState = await liveState();
const held = await stage().screenshot({ animations: 'disabled' });
await dispatchTouch('pointerup', 711, origin.x - 68, origin.y, 0);
await dispatchTouch('pointerup', 712, origin.x + 68, origin.y, 0);
await settle();
const settledState = await liveState();
const reference = await stage().screenshot({ animations: 'disabled' });
const background = await backgroundRgb();
const immediateCoverage = await compareCoverage(immediate, reference, 'all', background);
const heldCoverage = await compareCoverage(held, reference, 'all', background);
const afterZoom = await page.evaluate(() => document.querySelector('houseplan-card')._zoom);
diagnostics[name] = {
beforeZoom,
afterZoom,
immediateCoverage,
heldCoverage,
transformed: immediateState.transformed,
heldTransformed: heldState.transformed,
overflowImmediate: immediateState.allSceneOverflowOpen,
overflowHeld: heldState.allSceneOverflowOpen,
promotedImmediate: immediateState.allScenePromoted,
promotedHeld: heldState.allScenePromoted,
targetStableWhileHeld: JSON.stringify(immediateState.view) === JSON.stringify(heldState.view),
temporaryStylesCleared: settledState.allTemporaryStylesCleared,
};
checks[`${name}ActuallyZoomsOut`] = afterZoom < beforeZoom;
checks[`${name}ImmediateCoverage`] = immediateCoverage.sameSize
&& immediateCoverage.referenceScenePixels > 5000 && immediateCoverage.missing === 0;
checks[`${name}HeldCoverage`] = heldCoverage.sameSize
&& heldCoverage.referenceScenePixels > 5000 && heldCoverage.missing === 0;
checks[`${name}UsesTemporaryOverflow`] = immediateState.allScenePromoted
&& heldState.allScenePromoted
&& settledState.allTemporaryStylesCleared;
checks[`${name}TargetStableWhileHeld`] = diagnostics[name].targetStableWhileHeld;
};
// #579: observe the actual style attribute through several budget refreshes.
// Starting the observer after the first promoted frame excludes the legitimate
// initial style construction; from that point until pointerup every oldValue
// must already describe the same promoted compositor lifecycle.
const runStablePromotion = async () => {
await resetCamera();
await preparePointerCapture();
const origin = await pointerCentre();
await dispatchTouch('pointerdown', 721, origin.x - 80, origin.y);
await dispatchTouch('pointerdown', 722, origin.x + 80, origin.y);
await dispatchTouch('pointermove', 721, origin.x - 90, origin.y);
await dispatchTouch('pointermove', 722, origin.x + 90, origin.y);
await frame();
const initial = await liveState();
await page.evaluate(() => {
const card = document.querySelector('houseplan-card');
const scenes = [...card.renderRoot.querySelectorAll('[data-hp-live-viewbox]')];
const records = [];
const observer = new MutationObserver((batch) => records.push(...batch));
for (const scene of scenes) observer.observe(scene, {
attributes: true, attributeFilter: ['style', 'viewBox'], attributeOldValue: true,
});
window.__hp579Promotion = { observer, records };
});
// Force the exact second ownership boundary from the bug: Home Assistant or
// another reactive input may deliver a complete Lit frame while both touch
// contacts are still held. Waiting for incidental fixture activity made the
// mutation witness timing-dependent on slower Linux runners.
await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
card.requestUpdate();
await card.updateComplete;
});
await frame();
for (const distance of [112, 72, 118]) {
await page.waitForTimeout(110);
await dispatchTouch('pointermove', 721, origin.x - distance, origin.y);
await dispatchTouch('pointermove', 722, origin.x + distance, origin.y);
await frame();
}
const active = await page.evaluate(async () => {
await Promise.resolve();
const card = document.querySelector('houseplan-card');
const scenes = [...card.renderRoot.querySelectorAll('[data-hp-live-viewbox]')];
const state = window.__hp579Promotion;
const records = [...state.records, ...state.observer.takeRecords()];
state.observer.disconnect();
delete window.__hp579Promotion;
const styleRecords = records.filter((record) => record.attributeName === 'style');
const promoted = (text) => typeof text === 'string'
&& text.includes('overflow: visible') && text.includes('transform-origin:')
&& text.includes('will-change: transform') && text.includes('transform:');
return {
sceneCount: scenes.length,
styleMutations: styleRecords.length,
viewBoxMutations: records.filter((record) => record.attributeName === 'viewBox').length,
demotedOldValues: styleRecords.filter((record) => !promoted(record.oldValue)).length,
allPromoted: scenes.every((scene) => promoted(scene.getAttribute('style'))),
};
});
await dispatchTouch('pointerup', 721, origin.x - 118, origin.y, 0);
await dispatchTouch('pointerup', 722, origin.x + 118, origin.y, 0);
await settle();
const terminal = await liveState();
diagnostics.stablePromotion = { initial, active, terminal };
checks.stablePromotionStartsOnce = initial.allScenePromoted;
checks.stablePromotionSurvivesRefresh = active.sceneCount > 0 && active.allPromoted
&& active.demotedOldValues === 0;
// The forced Lit commit contributes one deliberate anchor write per scene;
// subtract it before judging the ordinary 100 ms / 15% refresh budget.
const budgetedViewBoxMutations = active.viewBoxMutations - active.sceneCount;
checks.stablePromotionKeepsViewBoxBudget = budgetedViewBoxMutations >= active.sceneCount * 3
&& budgetedViewBoxMutations <= active.sceneCount * 5;
checks.stablePromotionCleansAtTerminal = terminal.allTemporaryStylesCleared;
};
await page.evaluate(async () => {
const card = window.__card;
const config = structuredClone(card._serverCfg);
const space = config.spaces.find((item) => item.id === card._space) || config.spaces[0];
const poly = [[0, 0], [1, 0], [1, 1], [0, 1]];
space.rooms = [{
id: 'coverage-room',
name: 'Coverage room',
poly,
fill_mode: 'custom',
fill_color: '#d02020',
fill_opacity: 1,
}];
space.walls = poly.map((a, index) => ({
key: `coverage-wall-${index}`,
a,
b: poly[(index + 1) % poly.length],
cm: 15,
}));
config.settings = {
...(config.settings || {}), bg_color: '#123456', bg_mode: 'static',
};
space.settings = {
...(space.settings || {}),
bg_color: '#123456',
bg_mode: 'static',
fill_mode: 'custom',
custom_fill: { c: '#d02020', a: 1 },
glow_enabled: false,
show_borders: true,
};
card._serverCfg = config;
card._space = space.id;
card.requestUpdate();
await card.updateComplete;
});
await settle();
await runPan({ name: 'flatMouseRightEdge', kind: 'mouse', dx: -65, dy: 0, edge: 'right' });
await runPan({ name: 'flatMouseLeftEdge', kind: 'mouse', dx: 65, dy: 0, edge: 'left' });
await runPan({ name: 'flatMouseBottomEdge', kind: 'mouse', dx: 0, dy: -48, edge: 'bottom' });
await runPan({ name: 'flatMouseTopEdge', kind: 'mouse', dx: 0, dy: 48, edge: 'top' });
await runPan({ name: 'flatTouchRightEdge', kind: 'touch', dx: -65, dy: 0, edge: 'right' });
await runTouchZoomOut({ name: 'flatTouchZoomOut' });
await runStablePromotion();
// #579 AC4: every combination keeps the same expensive scene effects alive
// in both the immediate and held compositor frames. The custom fill, wall
// hatch and user background stay on in all four rows of the matrix.
for (const dayCycle of [false, true]) {
for (const glow of [false, true]) {
await configureEffectMatrix({ dayCycle, glow });
await runPan({
name: `effectsDay${Number(dayCycle)}Glow${Number(glow)}`,
kind: 'touch', dx: -65, dy: 0, edge: 'right', effects: { dayCycle, glow },
});
}
}
await page.evaluate(async () => {
history.replaceState(null, '', '?hp_alpha=1#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
const card = document.querySelector('houseplan-card');
await card.updateComplete;
await window.__hpEnsureHarnessIsoRuntime(card);
card._setProjection('iso');
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
checks.alphaIsoEnabled = await page.evaluate(() => {
const card = document.querySelector('houseplan-card');
return card._effectiveProjection() === 'iso'
&& card.renderRoot.querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4';
});
await runPan({ name: 'isoMouseRightEdge', kind: 'mouse', dx: -65, dy: 0, edge: 'right', parity: true });
await runTouchZoomOut({ name: 'isoTouchZoomOut' });
await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
card._setProjection('flat');
card.setConfig({ type: 'custom:houseplan-card', kiosk: true });
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
});
checks.kioskEnabled = await page.evaluate(() => document.querySelector('houseplan-card')._kiosk === true);
await runPan({ name: 'kioskTouchRightEdge', kind: 'touch', dx: -65, dy: 0, edge: 'right' });
checkAll(checks);
await finish(browser, { checks, diagnostics });

Some files were not shown because too many files have changed in this diff Show More