Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fff48f7b1 | ||
|
|
a74a50d331 | ||
|
|
416d2fae71 | ||
|
|
08bc58cc0e | ||
|
|
c142e4f7b5 | ||
|
|
ad10a5b099 | ||
|
|
76310fc981 | ||
|
|
0f466743c3 | ||
|
|
35ef231191 | ||
|
|
fab28af26f | ||
|
|
a54429d4a3 | ||
|
|
9598768167 | ||
|
|
aa1105468b | ||
|
|
42d1a8a9dc | ||
|
|
8b88a6f606 | ||
|
|
4c7e9815f3 | ||
|
|
1deb29e163 | ||
|
|
3048ac9195 | ||
|
|
654cb9d404 | ||
|
|
8dbfe5e6b2 | ||
|
|
706eb02d70 | ||
|
|
4962de1d5c | ||
|
|
31b4ddda55 | ||
|
|
072fb108cf | ||
|
|
24671d4358 | ||
|
|
6607a34973 | ||
|
|
e9e6eba5d1 | ||
|
|
a20ef2b9b0 | ||
|
|
e447364636 | ||
|
|
b40e8ff58d | ||
|
|
ef3e7a5440 | ||
|
|
403bcca680 | ||
|
|
91b57ae60e | ||
|
|
3d2c5ab3ee | ||
|
|
5d3f29c249 | ||
|
|
ec9d968c8a | ||
|
|
8915e49a58 | ||
|
|
d515c42177 | ||
|
|
ade3078d53 | ||
|
|
d21fba11d6 | ||
|
|
ed238059e1 | ||
|
|
8237bbc1cc | ||
|
|
05d62f053a | ||
|
|
3a1cd7b1ad | ||
|
|
fb3071d3c1 | ||
|
|
870237f3fb | ||
|
|
9fa5efbdac | ||
|
|
73b0cfff5b | ||
|
|
13d8df5554 | ||
|
|
9e1a2f4a07 | ||
|
|
16069d1dcc | ||
|
|
23f822475b | ||
|
|
ceee249210 | ||
|
|
d0c13bc555 | ||
|
|
11e9eb10df | ||
|
|
044f09a6a5 | ||
|
|
92b89d9b80 | ||
|
|
2bcde13158 | ||
|
|
7bbab4b61a | ||
|
|
9882046b86 | ||
|
|
a86be350b1 | ||
|
|
3d68a2fe23 | ||
|
|
2d7a1c9788 | ||
|
|
bb9aaefe2a | ||
|
|
6ed9aa1652 | ||
|
|
f5b20e07f6 | ||
|
|
c4cb0649ae | ||
|
|
baf0136d9a | ||
|
|
6149bcf866 | ||
|
|
e2d38d4e16 | ||
|
|
f2936813af | ||
|
|
a05458b6f3 | ||
|
|
5021c48446 | ||
|
|
8a3bf71ef1 | ||
|
|
97d40ea710 | ||
|
|
8e4f417f51 | ||
|
|
b8f662cdf1 | ||
|
|
d709ea110d | ||
|
|
9f695d8a5d | ||
|
|
0606a3664a | ||
|
|
ae3db2bacd | ||
|
|
c9bf16dec3 | ||
|
|
94bd932153 | ||
|
|
134a7029b4 | ||
|
|
cf7a9cc4fc | ||
|
|
146da62b0d | ||
|
|
cdfc6e59dc | ||
|
|
09fb02cf15 | ||
|
|
dd86bf955a | ||
|
|
97f38ecaae | ||
|
|
50b163c60c | ||
|
|
1f15ee143e | ||
|
|
0d989b79e2 | ||
|
|
a6076e585e | ||
|
|
a2578e6f14 | ||
|
|
b76e0d38ef | ||
|
|
b18d366e8c | ||
|
|
134e3e79da | ||
|
|
30508e1a5c | ||
|
|
3c58ad1e73 | ||
|
|
f4ec9d995b | ||
|
|
ff4e096858 | ||
|
|
5c0fe8e79f | ||
|
|
bf0bc1f4a6 | ||
|
|
04084581c0 | ||
|
|
341c9fcfce | ||
|
|
b7dbc21c42 | ||
|
|
14ea266112 | ||
|
|
255677556f | ||
|
|
953f15b967 | ||
|
|
c86e4bdd31 | ||
|
|
24ea768609 | ||
|
|
30dcc5b2cb | ||
|
|
a22f6f5411 | ||
|
|
aa910c31a3 | ||
|
|
72975e7514 | ||
|
|
25001ef7ab | ||
|
|
d93bcf2628 | ||
|
|
09bf6a95ec | ||
|
|
de3e1f00e1 | ||
|
|
653d94ef09 | ||
|
|
475f391cf9 | ||
|
|
3078962b64 | ||
|
|
6abb589f0c | ||
|
|
4aa6c85f98 | ||
|
|
9deed2a3c3 | ||
|
|
80b63b7ee8 | ||
|
|
024583bd22 | ||
|
|
f0e1c0b8cc | ||
|
|
86a301f618 | ||
|
|
1abd65a2be | ||
|
|
62a2e3b05b | ||
|
|
fa18ca81c9 | ||
|
|
ffa13c6628 | ||
|
|
5e1b508215 | ||
|
|
88702eedc6 | ||
|
|
d4eea609cf | ||
|
|
dc77852e0b | ||
|
|
f10c5b4374 | ||
|
|
9e64fc80ee | ||
|
|
d8e09cd1a0 | ||
|
|
da8edb1f0e | ||
|
|
1265036d2a | ||
|
|
a7b02db609 | ||
|
|
8070357266 | ||
|
|
2095a40f41 | ||
|
|
9c4a45a6b2 | ||
|
|
f5a6b47b39 | ||
|
|
707cb18f7e | ||
|
|
929044b932 | ||
|
|
4091d83af3 | ||
|
|
b84465e541 | ||
|
|
7ce3f6629f | ||
|
|
33a787a151 | ||
|
|
a0f72280ee | ||
|
|
88985d2e4c | ||
|
|
c629c9c238 | ||
|
|
9324d81b5f | ||
|
|
37238f3990 | ||
|
|
5a49c8c32d | ||
|
|
8b7725acb1 | ||
|
|
c84c430b2e | ||
|
|
0e44f8a69f | ||
|
|
7ff2b5aeaa | ||
|
|
4097132374 | ||
|
|
a729b5000d | ||
|
|
36ebabaaec | ||
|
|
de117d5042 | ||
|
|
6372c21eec | ||
|
|
5e31816881 | ||
|
|
b417ec4c9e | ||
|
|
15f8da8563 | ||
|
|
d87f834448 | ||
|
|
d5bdfde919 | ||
|
|
f559b3fe19 | ||
|
|
90ea1cab84 | ||
|
|
9debce3394 | ||
|
|
17b7b0ad73 | ||
|
|
7cb90e379b | ||
|
|
df8482c702 | ||
|
|
82fbad67d5 | ||
|
|
bc59917ea4 | ||
|
|
c39a8fe88e | ||
|
|
b0751497d4 | ||
|
|
c2ca6a55d2 | ||
|
|
1c49544831 | ||
|
|
b998b0b34a | ||
|
|
712d41b6d6 | ||
|
|
76558bf2a0 | ||
|
|
0621d53a49 | ||
|
|
bb25110dc4 | ||
|
|
f3cfb93e94 | ||
|
|
b27ae06ef8 | ||
|
|
7126fd8a02 | ||
|
|
02dce77c98 | ||
|
|
53bb3bf453 | ||
|
|
0ab685e380 | ||
|
|
4eb712be0e | ||
|
|
8e42f4e972 | ||
|
|
562313944f | ||
|
|
3b9f25ea65 | ||
|
|
cf8b8f0307 | ||
|
|
d5bca5dee5 | ||
|
|
61d98158ce | ||
|
|
e58d7d06f8 | ||
|
|
af339fda3b | ||
|
|
011e351249 | ||
|
|
235f60e693 | ||
|
|
8fac66e1c3 | ||
|
|
530eb0090d | ||
|
|
d327ec3d93 | ||
|
|
56be779ca7 | ||
|
|
5e430d9ede | ||
|
|
0cc7c60e8b | ||
|
|
b3dd9444b5 | ||
|
|
52dc08a0e5 | ||
|
|
78acf2c6ba | ||
|
|
dcb8f80de2 | ||
|
|
6f17b6cd4c | ||
|
|
153fa7a680 | ||
|
|
fc835cb97d | ||
|
|
499c0860df | ||
|
|
8df4ef710c | ||
|
|
1d51beade1 | ||
|
|
a49f7095ce | ||
|
|
3847baa5a0 | ||
|
|
5ce83feffa | ||
|
|
fd082ad460 | ||
|
|
85598638c1 | ||
|
|
0f7ec02352 | ||
|
|
715735d753 | ||
|
|
40607aa37f | ||
|
|
34423a6236 | ||
|
|
295d61f377 | ||
|
|
5f8e8ca7e8 | ||
|
|
108427dc77 | ||
|
|
3e7f7d3936 | ||
|
|
0d85807157 | ||
|
|
5cd9d0419a | ||
|
|
e00f62dca0 | ||
|
|
bbcc88caf1 | ||
|
|
66ac7d23ec | ||
|
|
2d735c56f9 | ||
|
|
48b001c646 | ||
|
|
dccbb80890 | ||
|
|
d11ad9c1c2 | ||
|
|
7188db8187 | ||
|
|
a5a73d1511 | ||
|
|
a312897527 | ||
|
|
7ac24a03a0 | ||
|
|
7574518575 | ||
|
|
12bdc0be68 | ||
|
|
8b7628285a | ||
|
|
37b1cbf74b | ||
|
|
40b226405d | ||
|
|
2f1be1a86a | ||
|
|
24cb6d113a | ||
|
|
6e2c75afc9 | ||
|
|
e5c217111c | ||
|
|
84ed38e3d5 | ||
|
|
3e38de07b3 | ||
|
|
aee0a5825b | ||
|
|
3d99f261ff | ||
|
|
204e728097 | ||
|
|
1557475af3 | ||
|
|
445a9a0cdf | ||
|
|
07d90be28f | ||
|
|
f56342479b | ||
|
|
bcff8c7157 | ||
|
|
a4af5a2008 | ||
|
|
9e1cc91277 | ||
|
|
dca0fd2876 | ||
|
|
838a60b3b0 | ||
|
|
2606fffa97 | ||
|
|
8afaa63caf | ||
|
|
5c0e8668f1 | ||
|
|
80e3dba700 | ||
|
|
c2c806fae3 | ||
|
|
6da6d9c699 | ||
|
|
0d641ffbfd | ||
|
|
e19f5b0a0e | ||
|
|
90d38b1685 | ||
|
|
683c9250b9 | ||
|
|
2bc688e6c3 | ||
|
|
349af7da66 | ||
|
|
59e05482fe | ||
|
|
c8c470ed57 | ||
|
|
e515bdaab8 | ||
|
|
a352c7c132 | ||
|
|
a372b354c6 | ||
|
|
9caa3c2316 | ||
|
|
df20da9676 | ||
|
|
bb33626f38 | ||
|
|
4c74e80aa9 | ||
|
|
eaeada16ae | ||
|
|
09d35c8d2c | ||
|
|
920e626948 | ||
|
|
a8321e32cc | ||
|
|
18c9f8e77c | ||
|
|
674e589ad8 | ||
|
|
26763c7ca4 | ||
|
|
52a56430ab | ||
|
|
6e07313460 | ||
|
|
f903e93c83 | ||
|
|
ae0e516af1 | ||
|
|
e7fca7b955 | ||
|
|
48f6f2e6a7 | ||
|
|
c949e0bbc9 | ||
|
|
5bb7197620 | ||
|
|
6a8658cab9 | ||
|
|
258d1990c8 | ||
|
|
2369c50607 | ||
|
|
c68d92f674 | ||
|
|
8bb43fd379 | ||
|
|
310bc6e4f8 | ||
|
|
1aa52d2107 | ||
|
|
f6e317d871 | ||
|
|
a005aae5b6 | ||
|
|
e45bc87c6d | ||
|
|
e1700757de | ||
|
|
1606407107 | ||
|
|
8dcc1cad4e | ||
|
|
224d0106fd | ||
|
|
ee3aa068fc | ||
|
|
19dc61db15 | ||
|
|
c66884b7f5 | ||
|
|
59bf216b60 | ||
|
|
6ab791e348 | ||
|
|
e6dfa9a752 | ||
|
|
5986332eda | ||
|
|
13054e246e |
@@ -12,3 +12,9 @@
|
||||
*.zip binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# #698: записи двух задач в `## Unreleased` друг другу не противоречат — при
|
||||
# слиянии и ребейзе git объединяет обе стороны сам (встроенный драйвер union),
|
||||
# и параллельные задачи не возвращаются в S6 из-за соседней строки ченджлога.
|
||||
docs/CHANGELOG.md merge=union
|
||||
docs/CHANGELOG.ru.md merge=union
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
name: "Бета: производные артефакты на dev · тело (#716)"
|
||||
|
||||
# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры
|
||||
# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на
|
||||
# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер.
|
||||
#
|
||||
# - Скриншоты: съёмка на `dev` тем же каноническим способом, что
|
||||
# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые
|
||||
# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды);
|
||||
# изменившийся кадр принимается, только если назван в `docs_expect_change`.
|
||||
# Ничего не изменилось — обновляется один отпечаток исходников.
|
||||
# - Golden: артефакт `golden-images` полного Validate на `dev`
|
||||
# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит
|
||||
# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует
|
||||
# validate-commit-provenance.mjs.
|
||||
#
|
||||
# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся
|
||||
# для просмотра: решение, какие кадры сдвинулись законно, принимает человек.
|
||||
|
||||
on:
|
||||
# #716: тело вызывается тонким файлом `beta-derived.yml` по ссылке `@dev`;
|
||||
# кнопка, входы ручного запуска, run-name и concurrency живут там. Без
|
||||
# файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе.
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Beta tag these artifacts are for, for example v1.79.0-beta.1"
|
||||
required: true
|
||||
type: string
|
||||
docs_expect_change:
|
||||
description: "Doc scenario ids expected to change, comma-separated (empty = none)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
golden_run:
|
||||
description: "Validate run id on dev whose golden-images artifact holds the shifted frames (empty = skip golden)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
golden_expect_change:
|
||||
description: "Golden scenes expected to change, comma-separated"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
golden_expect_new:
|
||||
description: "New golden scenes, comma-separated"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency уровня workflow — у вызывающего `beta-derived.yml` (#716).
|
||||
|
||||
jobs:
|
||||
accept:
|
||||
name: "Отпечаток, кадры и эталоны — одним коммитом в dev"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
# Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой
|
||||
# переписал бы все кадры без единого изменённого пикселя.
|
||||
- name: Установить oxipng
|
||||
env:
|
||||
OXIPNG_VERSION: 10.2.0
|
||||
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL -o "$asset" \
|
||||
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
|
||||
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
|
||||
run: node scripts/capture-determinism.mjs
|
||||
- name: Кадры документации — съёмка и приёмка
|
||||
id: docs
|
||||
env:
|
||||
EXPECT: ${{ inputs.docs_expect_change }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node demo/docs/capture.mjs --stability=3
|
||||
cand="$RUNNER_TEMP/docs-candidate"
|
||||
rm -rf "$cand" && mkdir -p "$cand"
|
||||
cp docs/images/*.png docs/images/screenshots.json "$cand/"
|
||||
# Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая
|
||||
# копия возвращается к dev, и заменить файлы может только она.
|
||||
git checkout -- docs/images
|
||||
git clean -fdq -- docs/images
|
||||
args=(--reviewed "--from=$cand")
|
||||
if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi
|
||||
node scripts/docs-accept.mjs "${args[@]}"
|
||||
if git diff --quiet -- docs/images; then changed=false; else changed=true; fi
|
||||
echo "changed=$changed" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- docs/images
|
||||
- name: Сохранить кандидата скриншотов для просмотра
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: docs-candidate-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/docs-candidate
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Эталоны golden из прогона Validate
|
||||
id: golden
|
||||
if: inputs.golden_run != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN: ${{ inputs.golden_run }}
|
||||
EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
|
||||
EXPECT_NEW: ${{ inputs.golden_expect_new }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac
|
||||
meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \
|
||||
--jq '[.path, .head_branch, .status, .html_url] | @tsv')
|
||||
IFS=$'\t' read -r path branch status url <<< "$meta"
|
||||
# Провенанс эталонов — завершённый Validate на dev: другой workflow
|
||||
# или ветка задачи доказательством для кандидата беты не являются.
|
||||
if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then
|
||||
echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)"
|
||||
exit 1
|
||||
fi
|
||||
from="$RUNNER_TEMP/golden-candidate"
|
||||
rm -rf "$from" && mkdir -p "$from"
|
||||
gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from"
|
||||
# Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его
|
||||
# отпечаток с исходниками и откажет, если dev ушёл вперёд.
|
||||
args=(--reviewed "--from=$from")
|
||||
if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi
|
||||
if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi
|
||||
node scripts/golden-accept.mjs "${args[@]}"
|
||||
if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi
|
||||
{ echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- demo/golden/baselines
|
||||
- name: Коммит в dev
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOCS_CHANGED: ${{ steps.docs.outputs.changed }}
|
||||
DOCS_EXPECT: ${{ inputs.docs_expect_change }}
|
||||
GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }}
|
||||
GOLDEN_URL: ${{ steps.golden.outputs.url }}
|
||||
GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
|
||||
GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# Хук pre-push гоняет process-gate; gate:small здесь не нужен —
|
||||
# продуктового кода коммит не несёт.
|
||||
HP_PREPUSH_GATE: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add -- docs/images demo/golden/baselines
|
||||
if git diff --cached --quiet; then
|
||||
echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего"
|
||||
exit 0
|
||||
fi
|
||||
msg="$RUNNER_TEMP/message.txt"
|
||||
{
|
||||
echo "docs: accept derived artifacts on dev for $TAG"
|
||||
echo ""
|
||||
echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)."
|
||||
echo "Прогон: $RUN_URL"
|
||||
if [ "$DOCS_CHANGED" = "true" ]; then
|
||||
echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}."
|
||||
fi
|
||||
if [ "$GOLDEN_CHANGED" = "true" ]; then
|
||||
echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}."
|
||||
fi
|
||||
echo ""
|
||||
if [ "$GOLDEN_CHANGED" = "true" ]; then
|
||||
echo "Release: $TAG"
|
||||
echo "Baseline-Reviewed: $GOLDEN_URL"
|
||||
fi
|
||||
echo "Issue: #697"
|
||||
echo "User-Visible: no"
|
||||
} > "$msg"
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F "$msg"
|
||||
git show --stat --format='%H %s' HEAD | head -40
|
||||
# #730: сдвигом dev считается только устаревший lease — отказ
|
||||
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705).
|
||||
# Скрипт — из dev: рабочая копия и есть dev. Сдвиг — прежний совет
|
||||
# перезапустить; отказ GitHub (право на workflow, правило ветки, хук)
|
||||
# перезапуск не лечит: причина и ответ git без токена — в журнале и в
|
||||
# сводке шага.
|
||||
push_err="$RUNNER_TEMP/beta-derived-push.stderr"
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
|
||||
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
|
||||
--stage=beta-derived --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
if [ "$kind" = "stale" ]; then
|
||||
echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое"
|
||||
else
|
||||
echo "::error::push производных артефактов в dev отклонён ($kind) — это не сдвиг dev, перезапуск не поможет; причина и ответ git — выше и в сводке шага"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -12,12 +12,19 @@
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
# там полный набор идёт заново. Адресат сигнала (#736) — задачи, чьи коммиты
|
||||
# классов A/B вошли в dev после последней зелёной ночи: job `night_red` пишет
|
||||
# каждой один комментарий (`scripts/night-red.mjs`, PROCESS.md §10.4).
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
#
|
||||
# #727: после Validate при любом его исходе ночь запускает пакетное ревью ship
|
||||
# (`ship-review.yml -f tag=nightly`) на SHA прогона Validate: оно читает код, а
|
||||
# не прогон. Ship-задача узнаёт о дефектах в ночь после слияния, а бета читает
|
||||
# только то, что ночь не прочла (PROCESS.md §11.7). Цвет ночи — цвет Validate.
|
||||
name: "Ночной полный прогон dev · тело (#623)"
|
||||
|
||||
on:
|
||||
@@ -34,6 +41,11 @@ jobs:
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
outputs:
|
||||
# #727: SHA прогона Validate — отдельным шагом до ожидания, красный Validate его не теряет.
|
||||
head_sha: ${{ steps.validate.outputs.head_sha }}
|
||||
# #736: прогон Validate для разбора красной ночи — тем же шагом, до ожидания.
|
||||
run_id: ${{ steps.validate.outputs.run_id }}
|
||||
steps:
|
||||
# #658: при плане 02:30 UTC ночь фактически стартовала в 07:42–08:05 и
|
||||
# кончалась в рабочее время владельца. Сдвиг старта больше часа — видимое
|
||||
@@ -55,7 +67,9 @@ jobs:
|
||||
if [ "$lag" -gt 60 ]; then
|
||||
echo "::warning::ночной прогон стартовал на $lag мин позже расписания ($SCHEDULE UTC) — очередь расписаний GitHub (#658)"
|
||||
fi
|
||||
- env:
|
||||
- name: "Запустить Validate и найти его прогон"
|
||||
id: validate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
@@ -80,5 +94,107 @@ jobs:
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
head_sha=$(gh run view "$run_id" --repo "$REPO" --json headSha --jq .headSha)
|
||||
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: "Дождаться Validate"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ steps.validate.outputs.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_id="$RUN_ID"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
|
||||
# #727 К6: ночное пакетное ревью ship на SHA прогона Validate — при любом его
|
||||
# исходе. Dispatch тонкого `ship-review.yml` (`github.token` порождает прогон,
|
||||
# как у Validate; входы и права тонких файлов уже в main). Job ждёт только
|
||||
# появления прогона, не его конца; не появился — предупреждение, не красная
|
||||
# ночь: цвет ночи — цвет Validate.
|
||||
ship_review:
|
||||
name: "Запустить ночное пакетное ревью ship на SHA прогона Validate"
|
||||
needs: dispatch
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: "Запустить ship-ревью и дождаться появления прогона"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
CANDIDATE: ${{ needs.dispatch.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$CANDIDATE" ]; then
|
||||
echo "::warning::нет SHA прогона Validate — ночное ship-ревью не запущено"
|
||||
echo "- Ship-ревью ночью: не запущено — нет SHA прогона Validate" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
since=$(date -u +%FT%TZ)
|
||||
if ! gh workflow run ship-review.yml --ref dev -f tag=nightly -f candidate="$CANDIDATE" --repo "$REPO"; then
|
||||
echo "::warning::ship-ревью ночью не запущено: dispatch отклонён — ночь не красится"
|
||||
echo "- Ship-ревью ночью: dispatch отклонён" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow ship-review.yml --branch dev \
|
||||
--event workflow_dispatch --json databaseId,createdAt --limit 5 \
|
||||
--jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty")
|
||||
[ -n "$run_id" ] && break
|
||||
done
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::warning::прогон ship-ревью не появился за 3 минуты — ночь не красится, её цвет — цвет Validate"
|
||||
echo "- Ship-ревью ночью (\`$CANDIDATE\`): прогон не появился за 3 минуты" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
echo "- Ship-ревью ночью (\`$CANDIDATE\`): ${{ github.server_url }}/$REPO/actions/runs/$run_id" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# #736: красная ночь — один комментарий в каждую задачу, чьи коммиты классов
|
||||
# A/B вошли в dev после последней зелёной ночи (`scripts/night-red.mjs`,
|
||||
# PROCESS.md §10.4). Только при красном dispatch с известным прогоном;
|
||||
# исход прогона (failure, а не cancelled/timed_out) скрипт читает сам.
|
||||
# Actions — `github.token`, issue — `HP_PROCESS_TOKEN`: `issues: write` у
|
||||
# ночи нет, и потолок тонкого `nightly.yml` в main не меняется. Сбой —
|
||||
# предупреждение, не красная ночь сверх Validate.
|
||||
night_red:
|
||||
name: "Красная ночь: комментарий в задачи, слитые после последней зелёной"
|
||||
needs: dispatch
|
||||
if: always() && needs.dispatch.result == 'failure' && needs.dispatch.outputs.run_id != ''
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
# Нужны коммиты и деревья, не блобы; скрипт и его импорты — без зависимостей, npm ci не нужен.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: "Комментарий в задачи диапазона от последней зелёной ночи до красной"
|
||||
env:
|
||||
ACTIONS_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
RED_RUN: ${{ needs.dispatch.outputs.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Сбои API, токена и git скрипт сам сводит к предупреждению; упавший
|
||||
# скрипт — тоже предупреждение: цвет ночи — цвет Validate.
|
||||
if ! node scripts/night-red.mjs --repo="$REPO" --red-run="$RED_RUN"; then
|
||||
echo "::warning::красная ночь (#736): скрипт упал — комментарии не написаны; цвет ночи — цвет Validate"
|
||||
echo "- Красная ночь (#736): скрипт упал — комментарии не написаны" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
@@ -5,6 +5,12 @@ name: "Метрики процесса · тело (#623)"
|
||||
# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется.
|
||||
# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate
|
||||
# за неделю) были собраны руками за час — теперь они стоят один запуск.
|
||||
#
|
||||
# #728: эффект процесса по трекам — отрезки, возвраты с причинами, находки
|
||||
# ship, job-минуты по стадиям и сравнение до/после 28.09. Прогон дороже:
|
||||
# jobs до 600 прогонов и таймлайны задач окна сравнения (≈ 600–800 запросов),
|
||||
# поэтому потолок 30 минут; трек инфраструктуры и объём задачи читаются из
|
||||
# истории git — нужна полная история dev.
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-metrics.yml` из ветки по умолчанию
|
||||
@@ -26,14 +32,15 @@ jobs:
|
||||
metrics:
|
||||
name: "Снимок недели: issue, раунды, прогоны"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
|
||||
# версия, которую проверил CI.
|
||||
# версия, которую проверил CI. Полная история (#728): коммиты задачи по
|
||||
# трейлеру `Issue: #NN` дают признак инфраструктуры и объём для сравнения.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
|
||||
@@ -54,7 +54,12 @@ jobs:
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
STATUS: ${{ github.event.workflow_run.status }}
|
||||
VALIDATE_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
# #751: без pipefail код конвейера — код tee, и исключение скрипта (gh,
|
||||
# API) проходило зелёным шагом: событие возобновления терялось до
|
||||
# прохода process-reconcile.
|
||||
run: |
|
||||
set -o pipefail
|
||||
node scripts/process-resume.mjs \
|
||||
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
|
||||
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
--event="$EVENT" --status="$STATUS" --run-id="$VALIDATE_RUN_ID" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -82,19 +82,14 @@ jobs:
|
||||
# очереди concurrency, пока владелец снял метку или поставил blocked —
|
||||
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
|
||||
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
labels=$(printf '%s\n' "$current" | paste -sd, -)
|
||||
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
|
||||
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
|
||||
BLOCKED=false; EXHAUSTED=false
|
||||
has blocked && BLOCKED=true
|
||||
has review-4 && EXHAUSTED=true
|
||||
# Треки #695 (PROCESS §5): `track:show` и `track:ship` — лимит 2, как у
|
||||
# прежних `small`/`trivial`; `track:ask` — 4. Цену захода по треку
|
||||
# (мутанты, ребейз, модель) решает prepare (#696).
|
||||
has small && SMALL=true
|
||||
has trivial && TRIVIAL=true
|
||||
has track:show && SMALL=true
|
||||
has track:ship && SMALL=true
|
||||
# Явный `track:ask` главнее прежних меток: метка владельца окончательна.
|
||||
if has track:ask; then SMALL=false; TRIVIAL=false; fi
|
||||
# Пути диффа ветки против dev — для признака инфраструктуры (§1, §5.1);
|
||||
# без ветки или при отказе compare API список пуст.
|
||||
changed=$(mktemp)
|
||||
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
@@ -111,10 +106,6 @@ jobs:
|
||||
stage=""
|
||||
fi
|
||||
|
||||
# Лимит циклов: 4 на `track:ask`, 2 на `track:show`/`track:ship` (PROCESS.md §4, §5).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
# Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227).
|
||||
#
|
||||
# `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен
|
||||
@@ -223,27 +214,28 @@ jobs:
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
|
||||
# §5.1 (r1 #695): инфраструктурная задача (§1) без трековой метки
|
||||
# идёт как `track:show` — лимит 2. Признак механический, как в §1:
|
||||
# в диффе ветки против dev ни одного файла класса A. Guard историю
|
||||
# не клонирует, поэтому пути — из compare API; 300 файлов — потолок
|
||||
# ответа, и полный список тогда не доказан: остаётся лимит 4.
|
||||
if [ -n "$branch" ] && ! has track:ship && ! has track:show && ! has track:ask \
|
||||
&& ! has small && ! has trivial; then
|
||||
changed=$(mktemp)
|
||||
if gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
|
||||
&& node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
import { classify } from "./scripts/process-gate.mjs";
|
||||
const files = readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean);
|
||||
process.exit(files.length > 0 && files.length < 300 && files.every((f) => classify(f) !== "A") ? 0 : 1);
|
||||
' "$changed"; then
|
||||
limit=2
|
||||
echo "инфраструктурная задача без трековой метки — трек show (§5.1), лимит 2"
|
||||
fi
|
||||
# Guard историю не клонирует, поэтому пути — из compare API. Отказ
|
||||
# API — пустой список: инфраструктура не доказана (fail-closed).
|
||||
if [ -n "$branch" ]; then
|
||||
gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
|
||||
|| : > "$changed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Трек и лимит циклов (PROCESS.md §4, §5, §5.1; #707) решает
|
||||
# process-track.mjs — та же функция, что у шага трека в prepare и у
|
||||
# пакета задачи. Своей логики трека здесь нет: две копии правила уже
|
||||
# расходились (#707 п.2). Ответ compare на 300 файлов и больше полного
|
||||
# списка не доказывает — скрипт оставляет `ask`/4. Скрипт не
|
||||
# отработал — лимит 4, как без доказанного трека: guard обязан
|
||||
# продолжить работу, а не встать.
|
||||
limit=4
|
||||
track_out=$(node scripts/process-track.mjs limit --labels="$labels" --files="$changed") \
|
||||
|| echo "::warning::process-track.mjs limit не отработал — лимит по умолчанию 4"
|
||||
printf '%s\n' "$track_out"
|
||||
new_limit=$(printf '%s\n' "$track_out" | sed -n 's/^limit=//p')
|
||||
case "$new_limit" in ''|*[!0-9]*) : ;; *) limit="$new_limit" ;; esac
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
# Ревьюшная метка обещает работу; если конвейер её не начал и промолчал,
|
||||
# задача стоит в этом статусе бесконечно и никто об этом не узнаёт.
|
||||
@@ -303,7 +295,7 @@ jobs:
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
echo "labels=$(printf '%s\n' "$current" | paste -sd, -)" >> "$GITHUB_OUTPUT"
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prepare:
|
||||
name: "Ревью: материал и deterministic gates"
|
||||
@@ -339,10 +331,21 @@ jobs:
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
# #765: SHA снимка скриптов конвейера — одна версия на заход: model_review
|
||||
# снимает расход скриптом из того же коммита dev.
|
||||
tools_sha: ${{ steps.tools.outputs.sha }}
|
||||
track: ${{ steps.track.outputs.track }}
|
||||
mutants: ${{ steps.track.outputs.mutants }}
|
||||
full: ${{ steps.track.outputs.full }}
|
||||
ship: ${{ steps.track.outputs.ship }}
|
||||
# #707: риск по изменённым участкам — заметка ревьюеру и строка риска
|
||||
# для комментария слияния ship (пакетное ревью, §11.7).
|
||||
risk_note: ${{ steps.track.outputs.risk_note }}
|
||||
ship_risk: ${{ steps.track.outputs.ship_risk }}
|
||||
# #726: подтверждение трека владельцем — решению по вердикту; заметка
|
||||
# маршрута (`route`/`criterion`) — в промпт Review.
|
||||
confirmed: ${{ steps.track.outputs.confirmed }}
|
||||
route_note: ${{ steps.track.outputs.route_note }}
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
@@ -383,6 +386,29 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
# #765 (продолжение #749): тело читается из dev (@dev, #623), значит, и
|
||||
# управляющие скрипты подготовки — из dev. Рабочая копия после «Перейти на
|
||||
# ветку задачи» — материал: ветка show/ship с чистым слиянием до ревью не
|
||||
# ребейзится (§10.4) и может нести scripts/, отставшие на дни или
|
||||
# подменённые. Раньше якорь тела issue, reuse (#499), Validate-gate (#510)
|
||||
# и проверку ТЗ (#517) исполняли скрипты ветки — материал сам участвовал в
|
||||
# решении о своём допуске к ревью. Снимок один на заход: SHA закрепляется
|
||||
# здесь, до любого git fetch трека и ребейза, и выходом `tools_sha` уходит
|
||||
# в model_review. validate.yml в снимке — по той же причине, что в #749:
|
||||
# ci-proof.mjs (validate-gate) читает его по пути от себя. Git, дифф и
|
||||
# документы по-прежнему судятся в рабочей копии — снимок только исполняет.
|
||||
- name: Скрипты конвейера — из dev (#765)
|
||||
id: tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch -q origin dev
|
||||
sha=$(git rev-parse origin/dev)
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive "$sha" scripts .github/workflows/validate.yml | tar -x -C "$tools"
|
||||
{ echo "dir=$tools"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT"
|
||||
echo "скрипты конвейера: dev ${sha:0:12}"
|
||||
|
||||
# Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся
|
||||
# в issue/<NN>-slug. Если ветка запушена — переключаемся на неё, иначе
|
||||
# ревьюер прочтёт dev и не найдёт того, что должен оценивать.
|
||||
@@ -413,14 +439,19 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутанты по диффу —
|
||||
# только `track:ask` и метка `ci:mutants`; `ship` сливается без модели,
|
||||
# если правка укладывается в механические рамки. Рамки судят дифф от
|
||||
# merge-base с dev — работу задачи, а не то, что принёс dev, — поэтому шаг
|
||||
# стоит до ребейза. Выход за рамки повышает трек до `show` здесь же:
|
||||
# повысить трек вправе любой, понизить — только владелец (§5). Скрипт
|
||||
# берётся из dev, как помощник ребейза: show/ship до ревью не ребейзятся,
|
||||
# и ветка, отставшая от dev, его может ещё не нести.
|
||||
# Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутантов в
|
||||
# разработке нет ни на одном треке (#709); `ship` сливается без модели,
|
||||
# если правка укладывается в механические рамки и не несёт риска по
|
||||
# изменённым участкам (#707) без подтверждения владельца. Рамки и риск
|
||||
# судят дифф от merge-base с dev — работу задачи, а не то, что принёс
|
||||
# dev, — поэтому шаг стоит до ребейза. Повышение ship → show — здесь же:
|
||||
# повысить трек вправе любой, понизить — только владелец (§5). Всё решает
|
||||
# ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только
|
||||
# исполняет: пишет комментарий из файла скрипта и меняет метки по его
|
||||
# флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`,
|
||||
# `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт — из снимка dev
|
||||
# (#765): show/ship до ревью не ребейзятся, и ветка, отставшая от dev, его
|
||||
# может ещё не нести.
|
||||
- name: "Трек задачи и рамки ship (#696)"
|
||||
id: track
|
||||
env:
|
||||
@@ -429,42 +460,26 @@ jobs:
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
LABELS: ${{ needs.guard.outputs.labels }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
tools="$RUNNER_TEMP/track-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
head=HEAD
|
||||
if [ -z "$BRANCH" ]; then head=origin/dev; fi
|
||||
out=$(node "$tools/scripts/process-track.mjs" resolve --labels="$LABELS" --base=origin/dev --head="$head")
|
||||
# Комментарии — для строки владельца «Трек: <x> — решение владельца»
|
||||
# (#707). Не прочитаны — пустой файл: происхождение не установлено.
|
||||
comments="$RUNNER_TEMP/track-comments.json"
|
||||
gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \
|
||||
|| : > "$comments"
|
||||
out=$(node "$TOOLS/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \
|
||||
--branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \
|
||||
--out="$RUNNER_TEMP/track" \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
|
||||
printf '%s\n' "$out"
|
||||
track=$(printf '%s\n' "$out" | sed -n 's/^track=//p')
|
||||
mutants=$(printf '%s\n' "$out" | sed -n 's/^mutants=//p')
|
||||
full=$(printf '%s\n' "$out" | sed -n 's/^full=//p')
|
||||
ship=false
|
||||
if [ "$track" = "ship" ] && [ "$STAGE" = "code" ] && [ -n "$BRANCH" ]; then
|
||||
limits=$(node "$tools/scripts/process-track.mjs" ship-limits --base=origin/dev --head=HEAD)
|
||||
printf '%s\n' "$limits"
|
||||
if printf '%s\n' "$limits" | grep -qx 'ship=true'; then
|
||||
ship=true
|
||||
else
|
||||
violations=$(printf '%s\n' "$limits" | sed -n 's/^violations=//p')
|
||||
track=show
|
||||
cat > /tmp/ship.md <<EOF
|
||||
**Трек повышен: \`track:ship\` → \`track:show\`.** Правка выходит за механические рамки ship (PROCESS.md §5): $violations.
|
||||
|
||||
Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC». Понизить трек обратно может только владелец.
|
||||
|
||||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label track:show --remove-label track:ship
|
||||
echo "track:ship -> track:show: $violations"
|
||||
fi
|
||||
if printf '%s\n' "$out" | grep -qx 'raise=true'; then
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$RUNNER_TEMP/track/raise.md"
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label track:show --remove-label track:ship
|
||||
echo "track:ship -> track:show"
|
||||
fi
|
||||
{ echo "track=$track"; echo "mutants=$mutants"; echo "full=${full:-false}"; echo "ship=$ship"; } >> "$GITHUB_OUTPUT"
|
||||
echo "- трек **$track** · мутанты по диффу: $mutants · полный набор: ${full:-false} · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
|
||||
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
|
||||
@@ -488,6 +503,7 @@ jobs:
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
TRACK: ${{ steps.track.outputs.track }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
@@ -524,13 +540,10 @@ jobs:
|
||||
# сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если
|
||||
# ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом
|
||||
# другом. Список конфликтов он снимает ДО abort (#364) и печатает в
|
||||
# stdout по строке. Помощник берётся из dev, а не из ветки: ветка,
|
||||
# отставшая от dev, его ещё не несёт.
|
||||
tools="$RUNNER_TEMP/rebase-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
# stdout по строке. Помощник — из снимка dev (#765), а не из ветки:
|
||||
# ветка, отставшая от dev, его ещё не несёт.
|
||||
code=0
|
||||
files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
|
||||
files=$(node "$TOOLS/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$?
|
||||
# Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой —
|
||||
# сбой самого помощника, конфликтом ветки он не выдаётся.
|
||||
if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then
|
||||
@@ -560,11 +573,38 @@ jobs:
|
||||
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
|
||||
# его молча. Расхождение lease — падение прогона, а не предупреждение:
|
||||
# ревью пошло бы по коду, которого на ветке уже нет.
|
||||
#
|
||||
# #705: отказ разбирает тот же код, что у слияния кандидата
|
||||
# (merge-candidate.mjs --push-refusal): ответ git — в журнал без
|
||||
# токена, исход — одним словом. Отказ GitHub по праву на workflow —
|
||||
# не сдвинутая ветка: задача возвращается автору без ревью с
|
||||
# комментарием, как при конфликте. Прочий отказ GitHub — ошибка
|
||||
# прогона со своей причиной. #730: причина и ответ git без токена —
|
||||
# ещё и в сводке шага (--summary; устаревший lease её не пишет).
|
||||
push_err="$RUNNER_TEMP/rebase-push.stderr"
|
||||
if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \
|
||||
"https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:refs/heads/$BRANCH"; then
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
|
||||
refusal="$RUNNER_TEMP/push-refusal.md"
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
|
||||
--branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
--comment="$refusal" --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
case "$kind" in
|
||||
stale)
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
;;
|
||||
workflow)
|
||||
{ echo 'refused=workflow'; echo "refusal_comment=$refusal"; } >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::GitHub не принял push ребейза $BRANCH: кандидат меняет workflow-файл, у токена конвейера нет права на workflow — ревью не запускается"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::GitHub отклонил push ребейза $BRANCH ($kind) — это не изменение ветки автором; ответ git выше"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
|
||||
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
|
||||
@@ -598,11 +638,12 @@ jobs:
|
||||
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == ''
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
@@ -628,7 +669,8 @@ jobs:
|
||||
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 { pathToFileURL } = require("node:url");
|
||||
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||||
});
|
||||
@@ -651,15 +693,16 @@ jobs:
|
||||
# теле issue, которого в дереве нет.
|
||||
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
|
||||
id: reuse
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == '' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
# Правка ТЗ между раундами обязана отменять повторное применение
|
||||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
out=$(node "$TOOLS/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"
|
||||
@@ -702,9 +745,25 @@ jobs:
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||||
# запускает Validate на материале и ждёт его — с мутантами на `ask` и по
|
||||
# метке `ci:mutants`, лёгкий на `show`/`ship` (#696). Лёгкому годится и
|
||||
# #705: GitHub не принял push приведённой ветки — кандидат меняет
|
||||
# workflow-файл, а у токена конвейера нет права на workflow. Ветку никто
|
||||
# не двигал, и ревью по неопубликованному ребейзу шло бы по коду, которого
|
||||
# на ветке нет. Возврат автору без ревью, как при конфликте: цикл не
|
||||
# израсходован, метка меняется. Текст комментария собрал разбор отказа.
|
||||
- name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)"
|
||||
if: steps.rebase.outputs.refused == 'workflow'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
COMMENT: ${{ steps.rebase.outputs.refusal_comment }}
|
||||
run: |
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$COMMENT"
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось: push отклонён по праву на workflow)"
|
||||
|
||||
# До ревью конвейер запускает Validate на материале и ждёт его — всегда
|
||||
# лёгкий: мутантов в разработке нет (#709), весь реестр — ночью. Лёгкому годится и
|
||||
# завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или
|
||||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||||
@@ -713,7 +772,7 @@ jobs:
|
||||
# слияние само дожидается Validate на кандидате.
|
||||
- name: Validate на материале
|
||||
id: gate
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -723,6 +782,7 @@ jobs:
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
# #697: `ci:full`/`ci:golden` — полный набор на материале.
|
||||
FULL: ${{ steps.track.outputs.full }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
@@ -736,8 +796,8 @@ jobs:
|
||||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||||
# прогон гейт и без ожидания возвращает сразу.
|
||||
set +e
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
--mutants="${MUTANTS:-true}" --full="${FULL:-false}"
|
||||
node "$TOOLS/scripts/validate-gate.mjs" --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
--mutants="${MUTANTS:-false}" --full="${FULL:-false}"
|
||||
code=$?
|
||||
set -e
|
||||
case "$code" in
|
||||
@@ -865,13 +925,15 @@ jobs:
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "хеша тела нет — сравнивать не с чем"
|
||||
exit 0
|
||||
fi
|
||||
out=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { pathToFileURL } = require("node:url");
|
||||
import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).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 ""; } };
|
||||
@@ -979,6 +1041,8 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
# #737: строка расхода сессии модели (scripts/model-usage.mjs).
|
||||
usage: ${{ steps.usage.outputs.line }}
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
@@ -1134,12 +1198,16 @@ jobs:
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5) · мутанты по диффу на материале: ${{ needs.prepare.outputs.mutants == 'false' && 'не запрашивались' || 'прогнаны Validate' }}
|
||||
Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5)
|
||||
|
||||
${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696). Отсутствие мутантов по диффу — не находка.' || '' }}
|
||||
${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696).' || '' }}
|
||||
|
||||
${{ needs.prepare.outputs.rebase_note }}
|
||||
|
||||
${{ needs.prepare.outputs.risk_note }}
|
||||
|
||||
${{ needs.prepare.outputs.route_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) || '' }}
|
||||
|
||||
Правила ревью в этом промпте не повторяются (#634): их канон —
|
||||
@@ -1152,15 +1220,15 @@ jobs:
|
||||
ограничитель: «features are built, improved and accepted only
|
||||
if they serve a job listed here». Первый вопрос к задаче —
|
||||
какую строку Core user jobs она закрывает.
|
||||
2. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
|
||||
2. AGENTS.md — карта пакета, правило №1, классы изменений, треки,
|
||||
трейлеры и ожидание вердикта; сами правила — по его ссылкам.
|
||||
3. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
|
||||
ревью ТЗ, код-ревью, объём гейтов, повторный раунд, находки и
|
||||
вердикт. Раздел PROCESS.md по ссылке открывай, когда пункт
|
||||
касается твоего решения; при расхождении прав PROCESS.md. Если
|
||||
файла в материале нет — читай PROCESS.md §2.4, §2.7, §2.10,
|
||||
§4, §7.2, §8, §12. Задача правит сам конвейер, гейты или
|
||||
процесс — PROCESS.md целиком, §10 в первую очередь.
|
||||
3. AGENTS.md — карта пакета, правило №1, классы изменений, треки,
|
||||
трейлеры и ожидание вердикта; сами правила — по его ссылкам.
|
||||
4. Тело issue #${{ github.event.issue.number }} и все комментарии.
|
||||
5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
|
||||
терминология интерфейса берётся оттуда, а не изобретается.
|
||||
@@ -1172,17 +1240,8 @@ jobs:
|
||||
FILTERING.md, STAIRS.md, RADAR.md, PDF-EXPORT.md,
|
||||
STYLING-HOOKS.md.
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.10, issue #214): найди вердикт и материал
|
||||
предыдущего раунда (блок «Материал раунда» его документа), объяви
|
||||
дельту `git diff <тот SHA>..HEAD` (для spec — дифф тела issue или
|
||||
файла ТЗ), по каждой находке покажи, чем именно она закрыта —
|
||||
строка кода или текста, а не заявление автора, — и заново проверяй
|
||||
только AC, чьё доказательство дельта задевает. Разбор остаётся
|
||||
ПОЛНЫМ, если дельта не локальна: ребейз на ушедший вперёд dev,
|
||||
смена контракта, новая подсистема, объём сопоставим с задачей.
|
||||
Сомневаешься — разбирай полностью и скажи почему. Сокращается
|
||||
объём РАЗБОРА, а не строгость.
|
||||
Цикл не первый — разбор по дельте (PROCESS.md §2.10):
|
||||
docs/process/REVIEWER.md, «Повторный раунд».
|
||||
|
||||
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
|
||||
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
|
||||
@@ -1225,22 +1284,8 @@ jobs:
|
||||
`npm run build` со сверкой трёх копий бандла. Свежесть скриншотов
|
||||
документации — не гейт задачи (#697): её обновляет бот на dev.
|
||||
${{ needs.guard.outputs.stage == 'spec' && 'Зависимости и Chromium на этапе spec не ставились (#696).' || (needs.prepare.outputs.track == 'show' && 'Зависимости установлены (`npm ci` не нужен); Chromium — только если тело issue называет смоук (#696).' || 'Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно.') }}
|
||||
По диффу и AC: браузерные смоки — названные в AC
|
||||
плюс вывод `node scripts/smoke-select.mjs --base <base> --head <head>`,
|
||||
приложенный к комментарию с решением по каждой строке: прогнал либо
|
||||
не прогнал и почему. Три вида ответа инструмента разные: «прямое
|
||||
совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь
|
||||
не доказана, и это не разрешение ничего не прогонять; слабые связи —
|
||||
повод посмотреть, а не обязанность прогонять;
|
||||
`npm run golden:verify` при метке `ci:golden`;
|
||||
`python -m pytest tests_backend -q` при правке
|
||||
`custom_components/**/*.py`; инварианты модели
|
||||
`npm run invariants -- --config <экспорт>` при правке геометрии или
|
||||
ссылок на неё (#254) — задача меняет геометрию, а инварианты в
|
||||
отчёте не названы, это непрогнанный гейт, а не мелочь;
|
||||
performance-профили, если названы в AC. Дисциплина «тест должен
|
||||
уметь падать» не отменяется, но применяется к тем тестам, которые
|
||||
ты прогонял.
|
||||
По диффу и AC — смоки, golden, pytest, инварианты, performance:
|
||||
docs/process/REVIEWER.md, «Объём гейтов».
|
||||
|
||||
**В комментарии обязателен перечень: какие гейты прогнал, какие нет и
|
||||
почему.** Раздел «чего не проверял» в документе ревью — не
|
||||
@@ -1248,13 +1293,9 @@ jobs:
|
||||
|
||||
Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь.
|
||||
|
||||
Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней
|
||||
же — без High это жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (#202); Low либо правится, либо снимается с записью.
|
||||
Жёлтый вердикт допустим и при полностью выполненных AC, если
|
||||
изменение не решает заявленный сценарий или ухудшает смежный.
|
||||
Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не
|
||||
даёт права менять скоуп.
|
||||
Серьёзность и вердикт — docs/process/REVIEWER.md, «Находки и вердикт»:
|
||||
Medium В СКОУПЕ — жёлтый вердикт и возврат автору, отдельный issue
|
||||
НЕ заводится (#202).
|
||||
|
||||
Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего
|
||||
поведения, который в этой ветке чинить нельзя) заведи отдельным
|
||||
@@ -1291,7 +1332,43 @@ jobs:
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--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"]}'
|
||||
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"},"route":{"type":"string","enum":["fix","reclassify"]},"criterion":{"type":"string"}},"required":["verdict","high","medium","summary","route"]}'
|
||||
|
||||
# #737: расход сессии — отчётная величина, как длительность стадии: по
|
||||
# нему ничего не решается, поэтому он идёт выходом job, а не через
|
||||
# запечатанный artifact (#556). Публикация разбирает строку строго. Сам
|
||||
# execution_file — все сообщения сессии, с результатами инструментов —
|
||||
# не выгружается и не печатается: скрипт берёт из него только `result`.
|
||||
# `always()` — расход упавшей сессии хотя бы виден в сводке.
|
||||
#
|
||||
# #765: рабочая копия здесь — материал ревью. На отставшей ветке show/ship
|
||||
# model-usage.mjs нет, и публикация молча писала `reason=missing`; ветка
|
||||
# могла и подменить скрипт. Скрипт берётся из того же коммита dev, что и
|
||||
# снимок подготовки (`tools_sha`). Снимок извлекается здесь, а не отдельным
|
||||
# шагом до Review: его сбой — сбой отчётного шага (continue-on-error), а не
|
||||
# всей стадии. От сессии модели, исполнявшейся на этом раннере, снимок не
|
||||
# защищает — поэтому строка остаётся недоверенным вводом и разбирается
|
||||
# строго при публикации (#556, #737).
|
||||
- name: Снять расход модели
|
||||
id: usage
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
EXEC: ${{ steps.review.outputs.execution_file }}
|
||||
TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$TOOLS_SHA" ]; then
|
||||
echo "::error::нет SHA снимка скриптов из prepare — расход не снят"
|
||||
exit 1
|
||||
fi
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git cat-file -e "${TOOLS_SHA}^{commit}" 2>/dev/null || git fetch -q origin "$TOOLS_SHA"
|
||||
git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"
|
||||
line=$(node "$tools/scripts/model-usage.mjs" --execution-file="$EXEC")
|
||||
echo "line=$line" >> "$GITHUB_OUTPUT"
|
||||
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Запечатать результат модели
|
||||
id: result
|
||||
@@ -1400,6 +1477,28 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# #749: тело читается из dev (@dev, #623) — значит, и скрипты, которые
|
||||
# оно зовёт со своими флагами и форматами, тоже из dev. Рабочая копия
|
||||
# после публикации документа — ветка задачи, а ветка show/ship с чистым
|
||||
# слиянием до ревью не ребейзится (§10.4) и несёт scripts/, отставшие на
|
||||
# дни: неизвестный флаг review-doc-guard.mjs молча теряется, старый
|
||||
# merge-candidate.mjs сливает по-старому. Рабочая копия — материал, а не
|
||||
# инструмент: git, документ и пути судятся в ней, скрипты зовутся отсюда.
|
||||
# Снимок один на job — одна версия на весь заход, а не смесь по шагам.
|
||||
# validate.yml в снимке обязателен: workflow-jobs.mjs читает его по пути
|
||||
# от себя, и без него ci-proof.mjs отвечает `failed (#622)` на каждом
|
||||
# слиянии кода.
|
||||
- name: Скрипты конвейера — из dev (#749)
|
||||
id: tools
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tools="$RUNNER_TEMP/dev-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git fetch -q origin dev
|
||||
git archive origin/dev scripts .github/workflows/validate.yml | tar -x -C "$tools"
|
||||
echo "dir=$tools" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Получить результат модели
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -1432,10 +1531,11 @@ jobs:
|
||||
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 }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dir="$RUNNER_TEMP/review-result"
|
||||
node scripts/review-result-gate.mjs --dir="$dir"
|
||||
node "$TOOLS/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 команды
|
||||
@@ -1461,9 +1561,17 @@ jobs:
|
||||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||||
OUT: ${{ steps.result.outputs.structured_output }}
|
||||
# #737: расход модели — последней строкой блока якорей; строка
|
||||
# недоверенная, скрипт разбирает её строго.
|
||||
USAGE: ${{ needs.model_review.outputs.usage }}
|
||||
# #749: скрипты — из снимка dev, рабочая копия — ветка задачи.
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
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)
|
||||
# #726: маршрут вердикта — хвостом строки якоря; вне словаря скрипт его не пишет.
|
||||
route=$(printf '%s' "$OUT" | jq -r '.route // empty' 2>/dev/null || true)
|
||||
criterion=$(printf '%s' "$OUT" | jq -r '.criterion // empty' 2>/dev/null || true)
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
@@ -1516,11 +1624,13 @@ jobs:
|
||||
# выводом — через раунд команда из §2.10 не работала. Машина же
|
||||
# снимает якоря в момент чтения материала и ошибиться в них не
|
||||
# может; блок помечен как машинный, чтобы никто не правил его руками.
|
||||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||||
node "$TOOLS/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"
|
||||
--usage="$USAGE" \
|
||||
--verdict="$verdict" --high="$high" \
|
||||
--route="$route" --criterion="$criterion"
|
||||
else
|
||||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||||
fi
|
||||
@@ -1533,7 +1643,7 @@ jobs:
|
||||
# задачи — один документ: индекс там конфликтовал бы с документами
|
||||
# соседей в dev по построению; его пересоберёт слияние кандидата.
|
||||
if [ -f "$doc" ] && [ "$target" = "dev" ]; then
|
||||
node scripts/reviews-index.mjs --dir=docs/reviews
|
||||
node "$TOOLS/scripts/reviews-index.mjs" --dir=docs/reviews
|
||||
git add -- docs/reviews/INDEX.md
|
||||
fi
|
||||
if git diff --cached --quiet; then
|
||||
@@ -1554,24 +1664,41 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
# Первый рубеж: что вообще проиндексировано.
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
git diff --cached --name-only | node "$TOOLS/scripts/review-doc-guard.mjs"
|
||||
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
|
||||
msg="$RUNNER_TEMP/review-doc-commit.txt"
|
||||
{
|
||||
echo "docs: review document for #$NUM"
|
||||
echo ""
|
||||
echo "Issue: #$NUM"
|
||||
echo "User-Visible: no"
|
||||
} > "$msg"
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<EOF
|
||||
docs: review document for #$NUM
|
||||
|
||||
Issue: #$NUM
|
||||
User-Visible: no
|
||||
EOF
|
||||
commit -q -F "$msg"
|
||||
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
|
||||
# судит намерение шага, этот — результат, а расходились они именно
|
||||
# тогда, когда база оказывалась не той.
|
||||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||||
git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs"
|
||||
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
|
||||
# либо dev, поэтому push нужен с явным перебазированием при гонке:
|
||||
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
|
||||
#
|
||||
# #723: гонкой считается только устаревший lease. Отказ разбирает код
|
||||
# слияния (merge-candidate.mjs --push-refusal, #705) — из снимка dev
|
||||
# (#749): ветка, отставшая от dev, его может не нести. Отказ GitHub
|
||||
# (право на workflow, правило ветки, хук) ребейз не лечит: шаг падает
|
||||
# сразу, причина и ответ git без токена — в журнале и в сводке шага;
|
||||
# метка не меняется.
|
||||
push_err="$RUNNER_TEMP/review-doc-push.stderr"
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"; then
|
||||
"HEAD:$target" 2> "$push_err"; then
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
|
||||
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
if [ "$kind" != "stale" ]; then
|
||||
echo "::error::push документа ревью в $target отклонён ($kind) — это не сдвиг $target, ребейз не поможет; причина и ответ git — выше и в сводке шага"
|
||||
exit 1
|
||||
fi
|
||||
git fetch -q origin "$target"
|
||||
if ! git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
@@ -1583,9 +1710,14 @@ jobs:
|
||||
fi
|
||||
# После ребейза набор путей другой — проверяется заново. Форс здесь
|
||||
# запрещён и не появляется: ветка двигается только вперёд.
|
||||
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
|
||||
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target"
|
||||
git diff --name-only "origin/$target...HEAD" | node "$TOOLS/scripts/review-doc-guard.mjs"
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
|
||||
"HEAD:$target" 2> "$push_err"; then
|
||||
kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" --ref="$target" \
|
||||
--stage=review-doc --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
echo "::error::документ ревью не опубликован в $target и после ребейза ($kind); причина и ответ git — выше"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Постусловие: до ветки дошёл именно ожидаемый файл. Коммит с
|
||||
# документом, названным не по формату, — тот же вердикт без
|
||||
@@ -1624,6 +1756,7 @@ jobs:
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
@@ -1632,7 +1765,7 @@ jobs:
|
||||
git fetch -q origin "$target"
|
||||
# Судится опубликованная версия, а не рабочая копия: именно её прочтёт
|
||||
# следующий раунд.
|
||||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||||
git show "origin/$target:$doc" | node "$TOOLS/scripts/review-doc-guard.mjs" --doc=-
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
@@ -1644,26 +1777,51 @@ jobs:
|
||||
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
|
||||
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
# #707: строка риска подтверждённого владельцем ship — для пакетного ревью.
|
||||
SHIP_RISK: ${{ needs.prepare.outputs.ship_risk }}
|
||||
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
# #726: маршрут вердикта — трек и его подтверждение владельцем (шаг
|
||||
# трека), бюджет этапа (guard), ветка материала с документами ревью.
|
||||
TRACK: ${{ needs.prepare.outputs.track }}
|
||||
CONFIRMED: ${{ needs.prepare.outputs.confirmed }}
|
||||
LABELS: ${{ needs.guard.outputs.labels }}
|
||||
SPENT: ${{ needs.guard.outputs.spent }}
|
||||
LIMIT: ${{ needs.guard.outputs.limit }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
# ship и повторно применённый зелёный (#499) модель не вызывали:
|
||||
# маршрута у них нет, решение прежнее — зелёный двигает вперёд.
|
||||
green_forward() {
|
||||
green=true
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S5-ready ;;
|
||||
code) from=S7-code-review; to=S8-merged ;;
|
||||
esac
|
||||
}
|
||||
green=""; from=""; to=""
|
||||
# Повторно применимый зелёный вердикт (#499) главнее ship: код уже
|
||||
# прочитан моделью, и пакетному ревью перечитывать его незачем.
|
||||
if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then
|
||||
# #696: `ship` в рамках (PROCESS.md §5) сливается без ревью модели
|
||||
# после зелёного лёгкого Validate. Комментарий — не вердикт ревью и
|
||||
# так себя не называет: ревью не было. Машинный маркер находит
|
||||
# пакетное ревью диапазона перед бетой (ship-review.mjs, §11.7).
|
||||
# пакетное ревью ship — ночное и перед бетой (ship-review.mjs, §11.7).
|
||||
verdict=green; high=0
|
||||
short=$(printf '%s' "$MATERIAL" | cut -c1-8)
|
||||
cat > /tmp/ship-merge.md <<EOF
|
||||
**Слияние без ревью модели: трек ship (PROCESS.md §5, #696).** Правка укладывается в механические рамки ship, лёгкий Validate на \`$short\` зелёный${VALIDATE_URL:+ ([прогон]($VALIDATE_URL))}. Код до слияния не читал никто: его прочтёт пакетное ревью диапазона перед бетой (PROCESS.md §11.7), документ \`docs/reviews/SHIP-REVIEW-<тег>.md\`.
|
||||
**Слияние без ревью модели: трек ship (PROCESS.md §5, #696).** Правка укладывается в механические рамки ship, лёгкий Validate на \`$short\` зелёный${VALIDATE_URL:+ ([прогон]($VALIDATE_URL))}. Код до слияния не читал никто: его прочтёт пакетное ревью ship — ночью (\`docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.md\`) и перед бетой то, что ночь не прочла (\`docs/reviews/SHIP-REVIEW-<тег>.md\`), PROCESS.md §11.7.
|
||||
|
||||
${SHIP_RISK}
|
||||
|
||||
<!-- hp:ship-merge material=$MATERIAL -->
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md
|
||||
green_forward
|
||||
elif [ "$REUSE" = "true" ]; then
|
||||
# Модель не вызывалась: вердикт — записанный конвейером зелёный
|
||||
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
|
||||
@@ -1673,28 +1831,47 @@ jobs:
|
||||
"Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC}
|
||||
|
||||
Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор."
|
||||
green_forward
|
||||
else
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
# #726: вердикт модели решает скрипт трека командой route — ОДИН вызов:
|
||||
# статус, метки трека, `blocked` и `review-4`, тело комментария
|
||||
# файлом и строка сводки. Вперёд двигает только зелёный (§7.2);
|
||||
# жёлтый и красный — цикл и возврат автору, на код-ревью show с
|
||||
# `route: reclassify` — повышение до ask или вопрос владельцу.
|
||||
# Bash только исполняет: комментарий — из файла скрипта, метки —
|
||||
# по его выходу. Скрипт — из снимка dev (#749): рабочая копия здесь —
|
||||
# ветка задачи после публикации, и ветка show/ship, отставшая от dev,
|
||||
# его может ещё не нести. Метки — текущие: шаг трека мог в этом же
|
||||
# прогоне сменить ship на show.
|
||||
printf '%s' "$OUT" > "$RUNNER_TEMP/route-verdict.json"
|
||||
labels=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels \
|
||||
--jq '[.labels[].name] | join(",")') || labels="$LABELS"
|
||||
route=$(node "$TOOLS/scripts/process-track.mjs" route --stage="$STAGE" --track="$TRACK" \
|
||||
--confirmed="$CONFIRMED" --labels="$labels" --verdict="$RUNNER_TEMP/route-verdict.json" \
|
||||
--spent="$SPENT" --limit="$LIMIT" --num="$NUM" --cycle="$CYCLE" --branch="$BRANCH" \
|
||||
--out="$RUNNER_TEMP/route" \
|
||||
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}")
|
||||
printf '%s\n' "$route"
|
||||
field() { printf '%s\n' "$route" | sed -n "s/^$1=//p"; }
|
||||
verdict=$(field verdict); high=$(field high)
|
||||
green=$(field green); from=$(field from); to=$(field to)
|
||||
add=$(field add_labels); remove=$(field remove_labels); comment=$(field comment)
|
||||
if [ -n "$comment" ]; then
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$comment"
|
||||
fi
|
||||
edit=()
|
||||
if [ -n "$add" ]; then edit+=(--add-label "$add"); fi
|
||||
if [ -n "$remove" ]; then edit+=(--remove-label "$remove"); fi
|
||||
if [ "${#edit[@]}" -gt 0 ]; then
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" "${edit[@]}"
|
||||
fi
|
||||
fi
|
||||
echo "вердикт: $verdict, High: $high"
|
||||
|
||||
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
|
||||
# автору: на прогоне #111 жёлтый означал, что AC описывает неверное
|
||||
# изменение контракта — реализовать такое ТЗ значит сделать ошибку
|
||||
# по инструкции. Оба считаются циклом.
|
||||
if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then
|
||||
green=true
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S5-ready ;;
|
||||
code) from=S7-code-review; to=S8-merged ;;
|
||||
esac
|
||||
else
|
||||
green=false
|
||||
case "$STAGE" in
|
||||
spec) from=S4-spec-review; to=S3-spec ;;
|
||||
code) from=S7-code-review; to=S6-in-progress ;;
|
||||
esac
|
||||
# Перехода нет — сбой шага, а не зелёный: метка остаётся, зовётся владелец.
|
||||
case "$green" in true|false) ;; *) echo "::error::решение по вердикту не дало исхода (green=$green)"; exit 1 ;; esac
|
||||
if [ -z "$from" ] || [ -z "$to" ]; then
|
||||
echo "::error::решение по вердикту не дало перехода ($from -> $to)"
|
||||
exit 1
|
||||
fi
|
||||
echo "green=$green" >> "$GITHUB_OUTPUT"
|
||||
echo "from=$from" >> "$GITHUB_OUTPUT"
|
||||
@@ -1746,14 +1923,18 @@ jobs:
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
# #696: show/ship сливаются по лёгкому Validate на кандидате.
|
||||
MUTANTS: ${{ needs.prepare.outputs.mutants }}
|
||||
# #749: из снимка dev — и суждение о Validate кандидата (ci-proof.mjs
|
||||
# с validate.yml снимка), и reviews-index.mjs, который скрипт зовёт
|
||||
# по пути от себя.
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||||
--issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-true}"
|
||||
node "$TOOLS/scripts/merge-candidate.mjs" --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||||
--issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-false}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
@@ -1762,16 +1943,20 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
FROM: ${{ steps.decide.outputs.from }}
|
||||
TOOLS: ${{ steps.tools.outputs.dir }}
|
||||
# Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к
|
||||
# автору: метка утверждала бы, что код в dev, а его там нет.
|
||||
# Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 —
|
||||
# когда кандидат не слит (конфликт, красный Validate, изменившийся
|
||||
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
|
||||
# #706: исход `rereview` возвращает задачу в ту же метку. Одним вызовом
|
||||
# `gh` она добавлялась и тут же снималась — задача оставалась без
|
||||
# статуса. Скрипт снимает и ставит её заново, чтобы событие `labeled`
|
||||
# запустило новый заход.
|
||||
run: |
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
echo "$FROM -> $TO"
|
||||
node "$TOOLS/scripts/status-label.mjs" --repo="${{ github.repository }}" \
|
||||
--issue="$NUM" --from="$FROM" --to="$TO"
|
||||
|
||||
- name: Сводка длительности стадий
|
||||
if: always()
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
name: "Бета: пакетное ревью ship · тело (#716)"
|
||||
|
||||
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
|
||||
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
|
||||
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
|
||||
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
|
||||
# диапазоне есть ship-задачи.
|
||||
#
|
||||
# Три job, как у release-review.yml (#551, #556): детерминированный вход,
|
||||
# модель без права записи, детерминированная публикация документа в `dev`.
|
||||
#
|
||||
# #727: `tag=nightly` — ночной режим. Его запускает `_nightly.yml` после
|
||||
# полного Validate с `candidate` = голова `dev` того прогона. Читаются только
|
||||
# ship-задачи без покрытия или изменившиеся после ревью (`shipCoverage`), документ —
|
||||
# `SHIP-REVIEW-<база>-dev-<sha12>.md`. Бета так же читает только дельту.
|
||||
|
||||
on:
|
||||
# #716: тело вызывается тонким файлом `ship-review.yml` по ссылке `@dev`;
|
||||
# кнопка, входы ручного запуска, run-name и concurrency живут там. Без
|
||||
# файла в ветке по умолчанию GitHub не даёт запустить workflow_dispatch вовсе.
|
||||
workflow_call:
|
||||
inputs:
|
||||
# #727: `nightly` — зарезервированное значение (ночной режим, candidate обязателен).
|
||||
tag:
|
||||
description: "Beta tag the review is for, for example v1.79.0-beta.1"
|
||||
required: true
|
||||
type: string
|
||||
candidate:
|
||||
description: "Exact candidate SHA; empty = the dev tip"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
force:
|
||||
description: "Review again even when the document already exists in dev"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency уровня workflow — у вызывающего `ship-review.yml` (#716).
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: "Ship-ревью: задачи диапазона"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
outputs:
|
||||
proceed: ${{ steps.range.outputs.proceed }}
|
||||
candidate: ${{ steps.range.outputs.candidate }}
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
doc: ${{ steps.range.outputs.doc }}
|
||||
issues: ${{ steps.range.outputs.issues }}
|
||||
mode: ${{ steps.range.outputs.mode }}
|
||||
patches: ${{ steps.range.outputs.patches }}
|
||||
subject: ${{ steps.range.outputs.subject }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Кандидат, база и ship-задачи
|
||||
id: range
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
CANDIDATE: ${{ inputs.candidate }}
|
||||
FORCE: ${{ inputs.force }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# Отказ prepare за `| tee` не должен читаться как «ship-задач нет» (#727).
|
||||
set -o pipefail
|
||||
git fetch -q --tags origin
|
||||
# #727: tag=nightly без candidate — отказ до подстановки вершины dev.
|
||||
node scripts/ship-review.mjs mode --tag="$TAG" --candidate="$CANDIDATE"
|
||||
if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi
|
||||
git cat-file -e "$CANDIDATE^{commit}"
|
||||
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
|
||||
doc=$(node scripts/ship-review.mjs doc --tag="$TAG" --candidate="$CANDIDATE")
|
||||
if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then
|
||||
echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
out="$RUNNER_TEMP/ship-review-input"
|
||||
node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" --force="$FORCE" \
|
||||
--out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt"
|
||||
cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
|
||||
issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt")
|
||||
if [ -z "$issues" ]; then
|
||||
# #727 К5: пустая дельта — все ship-задачи покрыты документами базы.
|
||||
note=$(sed -n 's/^note=//p' "$RUNNER_TEMP/range.txt")
|
||||
echo "::notice::${note:-ship-задач в диапазоне нет — ревью не нужно}"
|
||||
if [ -n "$note" ]; then echo "- $note" >> "$GITHUB_STEP_SUMMARY"; fi
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
(cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256)
|
||||
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Передать вход модели
|
||||
if: steps.range.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
model_review:
|
||||
name: "Ship-ревью: работа модели"
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.proceed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# #737: строка расхода сессии модели (scripts/model-usage.mjs).
|
||||
outputs:
|
||||
usage: ${{ steps.usage.outputs.line }}
|
||||
# Недоверенная стадия без права записи (#556): документ публикует
|
||||
# `publish`. `github_token` у шага Review обязателен — без него action
|
||||
# меняет OIDC на собственный App-токен с правом записи.
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.candidate }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Получить вход
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
|
||||
- name: Проверить вход и кандидата
|
||||
env:
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
run: |
|
||||
(cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256)
|
||||
test "$(git rev-parse HEAD)" = "$CANDIDATE"
|
||||
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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 chromium
|
||||
|
||||
# Тот же обход, что у конвейера (_process.yml, anthropics issue 1817).
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
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)
|
||||
ver="${ver:-2.1.265}"
|
||||
base=https://downloads.claude.ai/claude-code-releases
|
||||
bin="$HOME/.local/bin/claude"
|
||||
mkdir -p "$(dirname "$bin")"
|
||||
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
|
||||
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
|
||||
echo "$sum $bin" | sha256sum -c -
|
||||
chmod +x "$bin"
|
||||
"$bin" --version
|
||||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
|
||||
env:
|
||||
REVIEW_DOC: ${{ runner.temp }}/ship-review.md
|
||||
REVIEW_INPUT: ${{ runner.temp }}/ship-review-input
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты ревьюер кода проекта House Plan. Язык ответа — русский.
|
||||
|
||||
${{ needs.prepare.outputs.subject }}
|
||||
Кандидат ${{ needs.prepare.outputs.candidate }} (рабочая копия уже на нём) · база
|
||||
диапазона: ${{ needs.prepare.outputs.base || 'нет' }}.
|
||||
Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md
|
||||
(строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json.
|
||||
|
||||
Эти задачи шли по треку ship и слиты в dev без ревью модели: правка
|
||||
в механических рамках и зелёный лёгкий Validate. Ты — первый, кто
|
||||
читает их код. Правила — docs/process/REVIEWER.md, раздел
|
||||
«Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их
|
||||
первыми.
|
||||
|
||||
По каждой задаче: `git show <sha>` каждого коммита; делает ли код
|
||||
то, что сказано в строке ТЗ, и только это; не ломает ли соседнее
|
||||
поведение; не вышла ли правка из трека ship по смыслу (новый
|
||||
UX-контракт, геометрия, конфиг, перф) — это находка Medium с
|
||||
предложением трека. Исполняй, где дёшево: `npm test`, смоук,
|
||||
который называет строка ТЗ или выбирает
|
||||
`node scripts/smoke-select.mjs --base <sha>^ --head <sha>`.
|
||||
|
||||
Серьёзность: High — дефект, который увидит пользователь беты, или
|
||||
правка не делает заявленного; High блокирует публикацию беты.
|
||||
Medium — поведение вне заявленного или вне рамок трека; Low —
|
||||
остальное. Находка чинится отдельной задачей, ты ничего не правишь.
|
||||
|
||||
Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
|
||||
комментарии. Права на запись у тебя нет. Любые изменения рабочей
|
||||
копии будут отброшены — после проверок восстанови её сам
|
||||
(`git checkout -- . && git clean -fd`), если что-то менял.
|
||||
|
||||
Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный,
|
||||
вне репозитория). Первой строкой после заголовка —
|
||||
`Итог: High N · Medium N · Low N`. Затем по задаче на раздел
|
||||
`## #NN — заголовок`: что проверил (команда → результат), находки с
|
||||
воспроизведением, чего не проверял.
|
||||
|
||||
Затем верни JSON по схеме — последнее обязательное действие.
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--allowedTools Read,Write,Grep,Glob,Bash
|
||||
--json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}'
|
||||
|
||||
# #737: как у конвейера (_process.yml) — расход выходом job, не через
|
||||
# artifact; execution_file не выгружается и не печатается.
|
||||
- name: Снять расход модели
|
||||
id: usage
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
EXEC: ${{ steps.review.outputs.execution_file }}
|
||||
run: |
|
||||
line=$(node scripts/model-usage.mjs --execution-file="$EXEC")
|
||||
echo "line=$line" >> "$GITHUB_OUTPUT"
|
||||
echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Запечатать результат модели
|
||||
env:
|
||||
SOURCE: ${{ runner.temp }}/ship-review.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
mkdir -p "$dir"
|
||||
printf '%s' "$OUT" > "$dir/result.json"
|
||||
jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \
|
||||
"$dir/result.json" >/dev/null
|
||||
cp "$SOURCE" "$dir/ship-review.md"
|
||||
(cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256)
|
||||
|
||||
- name: Передать результат публикации
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: "Ship-ревью: документ в dev"
|
||||
needs: [prepare, model_review]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
|
||||
# Модель пишет только текст. Путь, машинный блок (его читает гейт беты),
|
||||
# индекс и коммит решает этот шаг. #727: `mode` и `patches` блока — из
|
||||
# prepare (детерминированный патч-набор), не из результата модели.
|
||||
- name: Опубликовать документ
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOC: ${{ needs.prepare.outputs.doc }}
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
BASE: ${{ needs.prepare.outputs.base }}
|
||||
ISSUES: ${{ needs.prepare.outputs.issues }}
|
||||
MODE: ${{ needs.prepare.outputs.mode }}
|
||||
PATCHES: ${{ needs.prepare.outputs.patches }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# #737: расход модели — строкой сразу после блока; недоверенный ввод.
|
||||
USAGE: ${{ needs.model_review.outputs.usage }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG" --candidate="$CANDIDATE")"
|
||||
high=$(jq -r '.high' "$dir/result.json")
|
||||
medium=$(jq -r '.medium' "$dir/result.json")
|
||||
low=$(jq -r '.low' "$dir/result.json")
|
||||
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#730, как #723).
|
||||
# #748: ночью — своё: имя документа без префикса, задача ночного режима.
|
||||
if [ "$MODE" = "nightly" ]; then
|
||||
night=${DOC##*/SHIP-REVIEW-}
|
||||
subject="docs: nightly ship review ${night%.md}"
|
||||
lead="Ночное пакетное ревью задач track:ship (PROCESS.md §11.7)."
|
||||
issue="#727"
|
||||
else
|
||||
subject="docs: ship review for $TAG"
|
||||
lead="Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7)."
|
||||
issue="#696"
|
||||
fi
|
||||
msg="$RUNNER_TEMP/ship-review-commit.txt"
|
||||
push_err="$RUNNER_TEMP/ship-review-push.stderr"
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
git clean -fdq
|
||||
mkdir -p docs/reviews
|
||||
{
|
||||
cat "$dir/ship-review.md"
|
||||
printf '\n\n'
|
||||
node -e '
|
||||
import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({
|
||||
tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null,
|
||||
issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number),
|
||||
high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]),
|
||||
runUrl: process.env.RUN_URL, mode: process.env.MODE, patches: m.parsePatches(process.env.PATCHES),
|
||||
usage: process.env.USAGE ?? "",
|
||||
})));
|
||||
' "$high" "$medium" "$low"
|
||||
} > "$DOC"
|
||||
node scripts/reviews-index.mjs --dir=docs/reviews --strict
|
||||
git add -- "$DOC" docs/reviews/INDEX.md
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
{
|
||||
echo "$subject"
|
||||
echo ""
|
||||
echo "$lead"
|
||||
echo "Задачи: $ISSUES. Итог: High $high · Medium $medium · Low $low."
|
||||
echo ""
|
||||
echo "Issue: $issue"
|
||||
echo "User-Visible: no"
|
||||
} > "$msg"
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F "$msg"
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
|
||||
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
|
||||
exit 0
|
||||
fi
|
||||
# #730: сдвигом dev считается только устаревший lease — отказ
|
||||
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705),
|
||||
# как у ревью релиза (#723). Скрипт — из dev: рабочая копия только что
|
||||
# сброшена на origin/dev. Отказ GitHub (право на workflow, правило
|
||||
# ветки, хук) повтор не лечит: шаг останавливается, причина и ответ
|
||||
# git без токена — в журнале и в сводке шага.
|
||||
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
|
||||
--stage=ship-review --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
if [ "$kind" != "stale" ]; then
|
||||
echo "::error::push $DOC в dev отклонён ($kind) — это не сдвиг dev, повтор не поможет; причина и ответ git — выше и в сводке шага"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
echo "::error::документ ревью не опубликован в dev за три попытки"
|
||||
exit 1
|
||||
|
||||
# #727 К8: High ночного документа — одна строка в каждую его задачу.
|
||||
# Повтор на тот же документ не пишется (метка hp:ship-review-high doc=…),
|
||||
# Medium и Low в issue не идут — их решает владелец (§11.7). Режим и
|
||||
# High проверяет и скрипт: шаг вне ночи или без High ничего не пишет.
|
||||
- name: High ночью — строка в задачи документа
|
||||
if: needs.prepare.outputs.mode == 'nightly'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
MODE: ${{ needs.prepare.outputs.mode }}
|
||||
DOC: ${{ needs.prepare.outputs.doc }}
|
||||
ISSUES: ${{ needs.prepare.outputs.issues }}
|
||||
run: |
|
||||
high=$(jq -r '.high' "$RUNNER_TEMP/ship-review-result/result.json")
|
||||
node scripts/ship-review.mjs comment-high --mode="$MODE" --high="$high" --doc="$DOC" \
|
||||
--issues="$ISSUES" --repo="${{ github.repository }}"
|
||||
@@ -1,22 +1,17 @@
|
||||
name: "Бета: производные артефакты на dev"
|
||||
run-name: "Derived artifacts ${{ inputs.tag }}"
|
||||
|
||||
# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры
|
||||
# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на
|
||||
# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер.
|
||||
#
|
||||
# - Скриншоты: съёмка на `dev` тем же каноническим способом, что
|
||||
# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые
|
||||
# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды);
|
||||
# изменившийся кадр принимается, только если назван в `docs_expect_change`.
|
||||
# Ничего не изменилось — обновляется один отпечаток исходников.
|
||||
# - Golden: артефакт `golden-images` полного Validate на `dev`
|
||||
# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит
|
||||
# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует
|
||||
# validate-commit-provenance.mjs.
|
||||
#
|
||||
# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся
|
||||
# для просмотра: решение, какие кадры сдвинулись законно, принимает человек.
|
||||
# Тонкий вызывающий файл (#716, устройство #623). `workflow_dispatch` GitHub
|
||||
# исполняет с выбранной ветки, но кнопку и сам запуск (`gh workflow run`)
|
||||
# даёт только workflow, чей файл лежит в ветке по умолчанию (`main`). Поэтому
|
||||
# здесь только то, что обязано жить там: триггер, входы ручного запуска,
|
||||
# run-name, права и concurrency. Тело — `_beta-derived.yml` по ссылке `@dev`:
|
||||
# правка съёмки и приёмки — один коммит в `dev`, а запуск с любой ветки (в
|
||||
# кнопке по умолчанию выбрана `main`) исполняет тело из `dev`.
|
||||
# Этот файл меняется, только когда меняются входы или потолок прав; тогда он
|
||||
# зеркалится в `main`, и preflight `workflow_sync` (validate.yml) держит копии
|
||||
# равными.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -53,162 +48,18 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
accept:
|
||||
name: "Отпечаток, кадры и эталоны — одним коммитом в dev"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
# Потолок прав тела: объединение job-level прав `_beta-derived.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
# Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой
|
||||
# переписал бы все кадры без единого изменённого пикселя.
|
||||
- name: Установить oxipng
|
||||
env:
|
||||
OXIPNG_VERSION: 10.2.0
|
||||
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL -o "$asset" \
|
||||
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
|
||||
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
|
||||
run: node scripts/capture-determinism.mjs
|
||||
- name: Кадры документации — съёмка и приёмка
|
||||
id: docs
|
||||
env:
|
||||
EXPECT: ${{ inputs.docs_expect_change }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node demo/docs/capture.mjs --stability=3
|
||||
cand="$RUNNER_TEMP/docs-candidate"
|
||||
rm -rf "$cand" && mkdir -p "$cand"
|
||||
cp docs/images/*.png docs/images/screenshots.json "$cand/"
|
||||
# Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая
|
||||
# копия возвращается к dev, и заменить файлы может только она.
|
||||
git checkout -- docs/images
|
||||
git clean -fdq -- docs/images
|
||||
args=(--reviewed "--from=$cand")
|
||||
if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi
|
||||
node scripts/docs-accept.mjs "${args[@]}"
|
||||
if git diff --quiet -- docs/images; then changed=false; else changed=true; fi
|
||||
echo "changed=$changed" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- docs/images
|
||||
- name: Сохранить кандидата скриншотов для просмотра
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: docs-candidate-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/docs-candidate
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Эталоны golden из прогона Validate
|
||||
id: golden
|
||||
if: inputs.golden_run != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN: ${{ inputs.golden_run }}
|
||||
EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
|
||||
EXPECT_NEW: ${{ inputs.golden_expect_new }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac
|
||||
meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \
|
||||
--jq '[.path, .head_branch, .status, .html_url] | @tsv')
|
||||
IFS=$'\t' read -r path branch status url <<< "$meta"
|
||||
# Провенанс эталонов — завершённый Validate на dev: другой workflow
|
||||
# или ветка задачи доказательством для кандидата беты не являются.
|
||||
if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then
|
||||
echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)"
|
||||
exit 1
|
||||
fi
|
||||
from="$RUNNER_TEMP/golden-candidate"
|
||||
rm -rf "$from" && mkdir -p "$from"
|
||||
gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from"
|
||||
# Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его
|
||||
# отпечаток с исходниками и откажет, если dev ушёл вперёд.
|
||||
args=(--reviewed "--from=$from")
|
||||
if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi
|
||||
if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi
|
||||
node scripts/golden-accept.mjs "${args[@]}"
|
||||
if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi
|
||||
{ echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- demo/golden/baselines
|
||||
- name: Коммит в dev
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOCS_CHANGED: ${{ steps.docs.outputs.changed }}
|
||||
DOCS_EXPECT: ${{ inputs.docs_expect_change }}
|
||||
GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }}
|
||||
GOLDEN_URL: ${{ steps.golden.outputs.url }}
|
||||
GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
|
||||
GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# Хук pre-push гоняет process-gate; gate:small здесь не нужен —
|
||||
# продуктового кода коммит не несёт.
|
||||
HP_PREPUSH_GATE: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add -- docs/images demo/golden/baselines
|
||||
if git diff --cached --quiet; then
|
||||
echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего"
|
||||
exit 0
|
||||
fi
|
||||
msg="$RUNNER_TEMP/message.txt"
|
||||
{
|
||||
echo "docs: accept derived artifacts on dev for $TAG"
|
||||
echo ""
|
||||
echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)."
|
||||
echo "Прогон: $RUN_URL"
|
||||
if [ "$DOCS_CHANGED" = "true" ]; then
|
||||
echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}."
|
||||
fi
|
||||
if [ "$GOLDEN_CHANGED" = "true" ]; then
|
||||
echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}."
|
||||
fi
|
||||
echo ""
|
||||
if [ "$GOLDEN_CHANGED" = "true" ]; then
|
||||
echo "Release: $TAG"
|
||||
echo "Baseline-Reviewed: $GOLDEN_URL"
|
||||
fi
|
||||
echo "Issue: #697"
|
||||
echo "User-Visible: no"
|
||||
} > "$msg"
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F "$msg"
|
||||
git show --stat --format='%H %s' HEAD | head -40
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
|
||||
echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое"
|
||||
exit 1
|
||||
fi
|
||||
echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY"
|
||||
uses: Matysh/houseplan-card/.github/workflows/_beta-derived.yml@dev # #716: тело съёмки и приёмки из dev
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
docs_expect_change: ${{ inputs.docs_expect_change }}
|
||||
golden_run: ${{ inputs.golden_run }}
|
||||
golden_expect_change: ${{ inputs.golden_expect_change }}
|
||||
golden_expect_new: ${{ inputs.golden_expect_new }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -40,6 +40,7 @@ jobs:
|
||||
profile:
|
||||
- large-house
|
||||
- isometric
|
||||
- isometric-backdrop
|
||||
- isometric-stage3
|
||||
- plan-snap
|
||||
- interaction
|
||||
@@ -47,6 +48,7 @@ jobs:
|
||||
- overlay
|
||||
- space-default
|
||||
- space-glow
|
||||
- led-strips
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -134,6 +136,10 @@ jobs:
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
;;
|
||||
isometric-backdrop)
|
||||
npm run benchmark:large-house-isometric-backdrop -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-backdrop-baseline.json
|
||||
npm run benchmark:large-house-isometric-backdrop -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-backdrop-candidate.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:isometric-stage3-dense -- --allow-stage2-base --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-baseline.json
|
||||
npm run benchmark:isometric-stage3-dense -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-stage3-candidate.json
|
||||
@@ -170,6 +176,14 @@ jobs:
|
||||
cp ../artifacts/performance/space-glow-candidate.json ../artifacts/performance/space-glow-baseline.json
|
||||
fi
|
||||
;;
|
||||
led-strips)
|
||||
# #780 led-strips-v1: base predates LED strips, so the profile is
|
||||
# judged by its absolute limits inside the runner; the zero-LED
|
||||
# View stays covered by the relative interaction profile above.
|
||||
npm run benchmark:led-strips -- --size=10x5 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-10x5.json
|
||||
npm run benchmark:led-strips -- --size=50x50 --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-50x50.json
|
||||
npm run benchmark:led-strips -- --size=none --samples=7 --warmups=1 --output=../artifacts/performance/led-strips-none.json
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown performance profile: $PROFILE"
|
||||
exit 1
|
||||
@@ -188,6 +202,9 @@ jobs:
|
||||
isometric)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-comparison.json
|
||||
;;
|
||||
isometric-backdrop)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric-backdrop.json --baseline=../artifacts/performance/isometric-backdrop-baseline.json --candidate=../artifacts/performance/isometric-backdrop-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-backdrop-comparison.json
|
||||
;;
|
||||
isometric-stage3)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-isometric-stage3-dense.json --baseline=../artifacts/performance/isometric-stage3-baseline.json --candidate=../artifacts/performance/isometric-stage3-candidate.json --baseline-sha="$(git -C ../baseline rev-parse HEAD)" --candidate-sha="$(git rev-parse HEAD)" --output=../artifacts/performance/isometric-stage3-comparison.json
|
||||
;;
|
||||
@@ -209,6 +226,9 @@ jobs:
|
||||
space-glow)
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-space-card-glow.json --baseline=../artifacts/performance/space-glow-baseline.json --candidate=../artifacts/performance/space-glow-candidate.json --output=../artifacts/performance/space-glow-comparison.json
|
||||
;;
|
||||
led-strips)
|
||||
echo "led-strips-v1: absolute limits and counters already enforced by the runner (budgets-led-strips.json)"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload full performance report
|
||||
|
||||
@@ -75,6 +75,9 @@ jobs:
|
||||
FORCE: ${{ inputs.force }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# Отказ prepare за `| tee` не должен идти дальше с неполным
|
||||
# GITHUB_OUTPUT и proceed=true (#751).
|
||||
set -o pipefail
|
||||
doc=$(node scripts/release-review.mjs doc --tag="$TAG")
|
||||
git fetch -q --tags origin
|
||||
if [ -z "$CANDIDATE" ]; then
|
||||
@@ -183,6 +186,14 @@ jobs:
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# #704: `release.yml` ставит ревью в очередь токеном GITHUB_TOKEN, и
|
||||
# прогон начинает `github-actions[bot]` (тип Bot). Action отказывает
|
||||
# ботам, которых нет в списке («Workflow initiated by non-human actor:
|
||||
# github-actions (type: Bot)», v1.78.0). Разрешён ровно этот бот —
|
||||
# не '*': любой другой бот по-прежнему отклоняется. Action сравнивает
|
||||
# имена без регистра и без суффикса `[bot]`. Человек, запустивший
|
||||
# ревью руками, списка не касается.
|
||||
allowed_bots: "github-actions[bot]"
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты независимый ревьюер релиза House Plan. Язык ответа — русский.
|
||||
@@ -295,6 +306,9 @@ jobs:
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
test "$DOC" = "$(node scripts/release-review.mjs doc --tag="$TAG")"
|
||||
counts=$(jq -r '"High \(.high) · Medium \(.medium) · Low \(.low)"' "$dir/result.json")
|
||||
# Сообщение коммита — построчно в файл, без heredoc в `run:` (#723).
|
||||
msg="$RUNNER_TEMP/release-review-commit.txt"
|
||||
push_err="$RUNNER_TEMP/release-review-push.stderr"
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
@@ -310,24 +324,36 @@ jobs:
|
||||
node scripts/reviews-index.mjs --dir=docs/reviews --strict
|
||||
git add -- "$DOC" docs/reviews/INDEX.md
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
{
|
||||
echo "docs: release review for $TAG"
|
||||
echo ""
|
||||
echo "Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5)."
|
||||
echo "Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем."
|
||||
echo ""
|
||||
echo "Issue: #638"
|
||||
echo "User-Visible: no"
|
||||
} > "$msg"
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<MSG
|
||||
docs: release review for $TAG
|
||||
|
||||
Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5).
|
||||
Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем.
|
||||
|
||||
Issue: #638
|
||||
User-Visible: no
|
||||
MSG
|
||||
commit -q -F "$msg"
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev 2> "$push_err"; then
|
||||
echo "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: $counts"
|
||||
exit 0
|
||||
fi
|
||||
# #723: сдвигом dev считается только устаревший lease — отказ
|
||||
# разбирает код слияния (merge-candidate.mjs --push-refusal, #705).
|
||||
# Отказ GitHub (право на workflow, правило ветки, хук) повтор не
|
||||
# лечит: шаг останавливается, причина и ответ git без токена — в
|
||||
# журнале и в сводке шага.
|
||||
kind=$(node scripts/merge-candidate.mjs --push-refusal="$push_err" --ref=dev \
|
||||
--stage=release-review --summary="$GITHUB_STEP_SUMMARY") || kind=unknown
|
||||
if [ "$kind" != "stale" ]; then
|
||||
echo "::error::push $DOC в dev отклонён ($kind) — это не сдвиг dev, повтор не поможет; причина и ответ git — выше и в сводке шага"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
|
||||
@@ -153,14 +153,60 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
# #704: сколько ждать, что dispatch стал прогоном, — выпуск не ждёт
|
||||
# дольше нескольких минут; укладывается в timeout-minutes job.
|
||||
APPEAR_SECONDS: 180
|
||||
POLL_SECONDS: 15
|
||||
run: |
|
||||
if gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \
|
||||
repo="${{ github.repository }}"
|
||||
# Отсчёт окна — до dispatch, с запасом минута на расхождение часов.
|
||||
since=$(( $(date -u +%s) - 60 ))
|
||||
if ! gh workflow run release-review.yml --repo "${{ github.repository }}" --ref dev \
|
||||
-f tag="$TAG" -f candidate="$SHA"; then
|
||||
echo "Независимое ревью $TAG поставлено в очередь: release-review.yml на dev" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::warning::ревью линии $TAG не запущено — выпуск продолжается; запустить руками: gh workflow run release-review.yml --ref dev -f tag=$TAG -f candidate=$SHA"
|
||||
echo "Независимое ревью $TAG: **не запущено** — dispatch release-review.yml отклонён." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
# #704: dispatch не возвращает прогона, и v1.78.0 показал цену: ревью
|
||||
# упало в прогоне, о котором выпуск не знал. Прогон ищется по workflow,
|
||||
# ветке dev, событию, времени и run-name «Release review <тег>».
|
||||
# Найден и стартовал — ссылка и статус в сводке; нет — предупреждение,
|
||||
# выпуск не блокируется.
|
||||
title="Release review $TAG"
|
||||
run=""
|
||||
started=false
|
||||
tries=$(( APPEAR_SECONDS / POLL_SECONDS ))
|
||||
for try in $(seq 1 "$tries"); do
|
||||
found=$(gh run list --repo "$repo" --workflow release-review.yml --branch dev \
|
||||
--event workflow_dispatch --limit 20 \
|
||||
--json databaseId,url,status,conclusion,displayTitle,createdAt \
|
||||
| jq -c --arg title "$title" --argjson since "$since" \
|
||||
'[.[] | select(.displayTitle == $title and (.createdAt | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) >= $since)] | sort_by(.createdAt) | last // empty') || found=""
|
||||
if [ -n "$found" ]; then
|
||||
run="$found"
|
||||
case "$(jq -r .status <<< "$run")" in
|
||||
in_progress|completed) started=true; break ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$try" -lt "$tries" ]; then sleep "$POLL_SECONDS"; fi
|
||||
done
|
||||
minutes=$(( (APPEAR_SECONDS + 59) / 60 ))
|
||||
if [ -z "$run" ]; then
|
||||
echo "::warning::прогон ревью линии $TAG не появился за $minutes мин — выпуск продолжается; проверить Actions → release-review.yml"
|
||||
echo "Независимое ревью $TAG: **не стартовало за $minutes мин** — прогон release-review.yml на dev не найден." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
url=$(jq -r .url <<< "$run")
|
||||
status=$(jq -r '.status + (if (.conclusion // "") != "" then " · " + .conclusion else "" end)' <<< "$run")
|
||||
if [ "$started" != "true" ]; then
|
||||
echo "::warning::ревью линии $TAG в очереди и не стартовало за $minutes мин: $url"
|
||||
echo "Независимое ревью $TAG: **не стартовало за $minutes мин** (в очереди) — [прогон]($url), статус $status." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$(jq -r '.conclusion // ""' <<< "$run")" = "failure" ]; then
|
||||
echo "::warning::ревью линии $TAG уже упало: $url"
|
||||
fi
|
||||
echo "Независимое ревью $TAG **запущено**: [прогон]($url), статус $status. Выпуск его не ждёт." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
gate:
|
||||
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
name: "Бета: пакетное ревью ship"
|
||||
run-name: "Ship review ${{ inputs.tag }}"
|
||||
|
||||
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
|
||||
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
|
||||
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
|
||||
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
|
||||
# диапазоне есть ship-задачи.
|
||||
#
|
||||
# Только `workflow_dispatch`: файл исполняется с ветки прогона (`--ref dev`),
|
||||
# зеркало в `main` не нужно. Три job, как у release-review.yml (#551, #556):
|
||||
# детерминированный вход, модель без права записи, детерминированная
|
||||
# публикация документа в `dev`.
|
||||
# Тонкий вызывающий файл (#716, устройство #623). `workflow_dispatch` GitHub
|
||||
# исполняет с выбранной ветки, но кнопку и сам запуск (`gh workflow run`)
|
||||
# даёт только workflow, чей файл лежит в ветке по умолчанию (`main`). Поэтому
|
||||
# здесь только то, что обязано жить там: триггер, входы ручного запуска,
|
||||
# run-name, права и concurrency. Тело — `_ship-review.yml` по ссылке `@dev`:
|
||||
# правка ревью — один коммит в `dev`, а запуск с любой ветки (в кнопке по
|
||||
# умолчанию выбрана `main`) исполняет тело из `dev`.
|
||||
# Этот файл меняется, только когда меняются входы или потолок прав; тогда он
|
||||
# зеркалится в `main`, и preflight `workflow_sync` (validate.yml) держит копии
|
||||
# равными.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -37,298 +38,16 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: "Ship-ревью: задачи диапазона"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
# Потолок прав тела: объединение job-level прав `_ship-review.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
outputs:
|
||||
proceed: ${{ steps.range.outputs.proceed }}
|
||||
candidate: ${{ steps.range.outputs.candidate }}
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
doc: ${{ steps.range.outputs.doc }}
|
||||
issues: ${{ steps.range.outputs.issues }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Кандидат, база и ship-задачи
|
||||
id: range
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
CANDIDATE: ${{ inputs.candidate }}
|
||||
FORCE: ${{ inputs.force }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
doc=$(node scripts/ship-review.mjs doc --tag="$TAG")
|
||||
git fetch -q --tags origin
|
||||
if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi
|
||||
git cat-file -e "$CANDIDATE^{commit}"
|
||||
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
|
||||
if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then
|
||||
echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
out="$RUNNER_TEMP/ship-review-input"
|
||||
node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \
|
||||
--out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt"
|
||||
cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
|
||||
issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt")
|
||||
if [ -z "$issues" ]; then
|
||||
echo "::notice::ship-задач в диапазоне нет — ревью не нужно"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
(cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256)
|
||||
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Передать вход модели
|
||||
if: steps.range.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
model_review:
|
||||
name: "Ship-ревью: работа модели"
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.proceed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# Недоверенная стадия без права записи (#556): документ публикует
|
||||
# `publish`. `github_token` у шага Review обязателен — без него action
|
||||
# меняет OIDC на собственный App-токен с правом записи.
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.candidate }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Получить вход
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
|
||||
- name: Проверить вход и кандидата
|
||||
env:
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
run: |
|
||||
(cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256)
|
||||
test "$(git rev-parse HEAD)" = "$CANDIDATE"
|
||||
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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 chromium
|
||||
|
||||
# Тот же обход, что у конвейера (_process.yml, anthropics issue 1817).
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
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)
|
||||
ver="${ver:-2.1.265}"
|
||||
base=https://downloads.claude.ai/claude-code-releases
|
||||
bin="$HOME/.local/bin/claude"
|
||||
mkdir -p "$(dirname "$bin")"
|
||||
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
|
||||
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
|
||||
echo "$sum $bin" | sha256sum -c -
|
||||
chmod +x "$bin"
|
||||
"$bin" --version
|
||||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
|
||||
env:
|
||||
REVIEW_DOC: ${{ runner.temp }}/ship-review.md
|
||||
REVIEW_INPUT: ${{ runner.temp }}/ship-review-input
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты ревьюер кода проекта House Plan. Язык ответа — русский.
|
||||
|
||||
Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }}
|
||||
(рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}.
|
||||
Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md
|
||||
(строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json.
|
||||
|
||||
Эти задачи шли по треку ship и слиты в dev без ревью модели: правка
|
||||
в механических рамках и зелёный лёгкий Validate. Ты — первый, кто
|
||||
читает их код. Правила — docs/process/REVIEWER.md, раздел
|
||||
«Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их
|
||||
первыми.
|
||||
|
||||
По каждой задаче: `git show <sha>` каждого коммита; делает ли код
|
||||
то, что сказано в строке ТЗ, и только это; не ломает ли соседнее
|
||||
поведение; не вышла ли правка из трека ship по смыслу (новый
|
||||
UX-контракт, геометрия, конфиг, перф) — это находка Medium с
|
||||
предложением трека. Исполняй, где дёшево: `npm test`, смоук,
|
||||
который называет строка ТЗ или выбирает
|
||||
`node scripts/smoke-select.mjs --base <sha>^ --head <sha>`.
|
||||
|
||||
Серьёзность: High — дефект, который увидит пользователь беты, или
|
||||
правка не делает заявленного; High блокирует публикацию беты.
|
||||
Medium — поведение вне заявленного или вне рамок трека; Low —
|
||||
остальное. Находка чинится отдельной задачей, ты ничего не правишь.
|
||||
|
||||
Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
|
||||
комментарии. Права на запись у тебя нет. Любые изменения рабочей
|
||||
копии будут отброшены — после проверок восстанови её сам
|
||||
(`git checkout -- . && git clean -fd`), если что-то менял.
|
||||
|
||||
Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный,
|
||||
вне репозитория). Первой строкой после заголовка —
|
||||
`Итог: High N · Medium N · Low N`. Затем по задаче на раздел
|
||||
`## #NN — заголовок`: что проверил (команда → результат), находки с
|
||||
воспроизведением, чего не проверял.
|
||||
|
||||
Затем верни JSON по схеме — последнее обязательное действие.
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--allowedTools Read,Write,Grep,Glob,Bash
|
||||
--json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}'
|
||||
|
||||
- name: Запечатать результат модели
|
||||
env:
|
||||
SOURCE: ${{ runner.temp }}/ship-review.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
mkdir -p "$dir"
|
||||
printf '%s' "$OUT" > "$dir/result.json"
|
||||
jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \
|
||||
"$dir/result.json" >/dev/null
|
||||
cp "$SOURCE" "$dir/ship-review.md"
|
||||
(cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256)
|
||||
|
||||
- name: Передать результат публикации
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: "Ship-ревью: документ в dev"
|
||||
needs: [prepare, model_review]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
|
||||
# Модель пишет только текст. Путь, машинный блок (его читает гейт беты),
|
||||
# индекс и коммит решает этот шаг.
|
||||
- name: Опубликовать документ
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOC: ${{ needs.prepare.outputs.doc }}
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
BASE: ${{ needs.prepare.outputs.base }}
|
||||
ISSUES: ${{ needs.prepare.outputs.issues }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")"
|
||||
high=$(jq -r '.high' "$dir/result.json")
|
||||
medium=$(jq -r '.medium' "$dir/result.json")
|
||||
low=$(jq -r '.low' "$dir/result.json")
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
git clean -fdq
|
||||
mkdir -p docs/reviews
|
||||
{
|
||||
cat "$dir/ship-review.md"
|
||||
printf '\n\n'
|
||||
node -e '
|
||||
import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({
|
||||
tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null,
|
||||
issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number),
|
||||
high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]),
|
||||
runUrl: process.env.RUN_URL,
|
||||
})));
|
||||
' "$high" "$medium" "$low"
|
||||
} > "$DOC"
|
||||
node scripts/reviews-index.mjs --dir=docs/reviews --strict
|
||||
git add -- "$DOC" docs/reviews/INDEX.md
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<MSG
|
||||
docs: ship review for $TAG
|
||||
|
||||
Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7).
|
||||
Задачи: $ISSUES. Итог: High $high · Medium $medium · Low $low.
|
||||
|
||||
Issue: #696
|
||||
User-Visible: no
|
||||
MSG
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
|
||||
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
echo "::error::документ ревью не опубликован в dev за три попытки"
|
||||
exit 1
|
||||
uses: Matysh/houseplan-card/.github/workflows/_ship-review.yml@dev # #716: тело ревью из dev
|
||||
with:
|
||||
tag: ${{ inputs.tag }}
|
||||
candidate: ${{ inputs.candidate }}
|
||||
force: ${{ inputs.force }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -21,12 +21,11 @@ on:
|
||||
description: 'Полный набор: смоки, golden, performance_smoke (без мутантов — #601)'
|
||||
type: boolean
|
||||
default: true
|
||||
# Мутанты по диффу бегут только по явному запросу (#510, #601):
|
||||
# ревью-конвейер и слияние кандидата запускают `-f full=false
|
||||
# -f mutants=true` на материале. `full=true`, трейлер `Release:` и ночь
|
||||
# их не включают — ночью идёт полный реестр (mutation-gate.yml).
|
||||
# #709: мутанты в разработке не гоняются — вход оставлен, чтобы старые
|
||||
# вызовы `-f mutants=…` не падали, но job мутантов по диффу он больше не
|
||||
# включает (classify-changes.mjs). Весь реестр — ночью (mutation-gate.yml).
|
||||
mutants:
|
||||
description: 'Мутанты по диффу на этом SHA (кандидат ревью или слияния)'
|
||||
description: 'Не действует с #709: мутанты — только ночной полный прогон'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -53,12 +52,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||||
# обращается к API сверх содержимого репозитория.
|
||||
# проверка 8 процессного гейта, `issues: write` — одно issue о расхождении
|
||||
# зеркала workflow на push в dev (#700). Права перечислены явно, потому что
|
||||
# job обращается к API сверх содержимого репозитория.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
issues: write
|
||||
steps:
|
||||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
@@ -68,8 +68,10 @@ jobs:
|
||||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||||
# `git show origin/main:.github/workflows/<тонкий файл>`: по блобу на файл.
|
||||
# `fetch-tags: true` (#703): теги релиза — граница проверенного материала
|
||||
# для базы диапазона, а checkout по умолчанию качает с `--no-tags`.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
with: { fetch-depth: 0, filter: 'blob:none', fetch-tags: true }
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
|
||||
@@ -81,19 +83,24 @@ jobs:
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
# #697: на ветке задачи `Release:` строгий режим не включает.
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
# #700: на ветке задачи упавший чужой сайт — предупреждение, а не
|
||||
# красный preflight; push в dev, кандидат и релиз судят ссылки строго.
|
||||
external=--external
|
||||
case "$REF" in refs/heads/issue/*) external=--external=warn ;; esac
|
||||
# #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой
|
||||
# `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)`
|
||||
# строки схлопываются через пробел, сравнение не совпадало никогда,
|
||||
# и строгий режим не включился ни на одном кандидате.
|
||||
mode=$(node scripts/classify-changes.mjs --screenshots-mode)
|
||||
echo "скриншоты документации: режим $mode"
|
||||
node scripts/check-docs.mjs --external --screenshots=$mode
|
||||
node scripts/check-docs.mjs "$external" --screenshots=$mode
|
||||
|
||||
# #635: `docs/reviews/INDEX.md` — снимок каталога ревью; расхождение с
|
||||
# каталогом — невидимые через индекс документы (r2 #635 H1). Гейт стоит
|
||||
@@ -117,9 +124,16 @@ jobs:
|
||||
# ни одним событием. Тонкий файл сверяется: правка триггера или потолка
|
||||
# прав, не доехавшая до `main`, действовала бы только в dev-копии.
|
||||
#
|
||||
# #716: `ship-review.yml` и `beta-derived.yml` — только `workflow_dispatch`,
|
||||
# он исполняет файл с выбранной ветки, но запуск GitHub даёт лишь workflow,
|
||||
# чей файл есть в `main`. Шагам беты он нужен до промоушена, поэтому они
|
||||
# устроены так же и сверяются здесь же: правка входа или потолка прав,
|
||||
# не доехавшая до `main`, не действовала бы в запуске с `main`, а именно
|
||||
# его кнопка выбирает по умолчанию.
|
||||
#
|
||||
# `performance.yml` не входит: по расписанию он судит `main` собственным
|
||||
# телом из `main`, расхождение с `dev` до промоушена законно. Список ниже
|
||||
# держит равным множеству файлов с такими триггерами
|
||||
# держит равным множеству тонких файлов
|
||||
# test/default-branch-workflows.test.mjs.
|
||||
- name: "Процесс: тонкие вызывающие workflow идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
@@ -127,7 +141,7 @@ jobs:
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do
|
||||
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml ship-review.yml beta-derived.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
@@ -140,6 +154,39 @@ jobs:
|
||||
done
|
||||
exit $status
|
||||
|
||||
# #700: расхождение зеркала на dev — одно открытое issue владельцу, как у
|
||||
# ночного мутационного гейта (#472): чинит его тот, кто зеркалит в main, а
|
||||
# не автор задачи, чья ветка ни при чём. Сбой API — предупреждение: шаг
|
||||
# сообщает, а не судит.
|
||||
- name: "Расхождение зеркала на dev — issue владельцу"
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.workflow_sync.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
marker="[workflow-sync]"
|
||||
# Список не прочитан — нового issue нет: иначе сбой сети или
|
||||
# рейт-лимит плодил бы дубликат к уже открытому (r1 #700).
|
||||
if ! existing=$(gh issue list --repo "$REPO" --state open --search "\"$marker\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith("[workflow-sync]"))][0].number // empty'); then
|
||||
echo "::warning::открытые issue не прочитаны — новое не заводится, чтобы не завести дубликат"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body "Расхождение повторилось: $RUN_URL" \
|
||||
|| echo "::warning::комментарий в #$existing не оставлен"
|
||||
exit 0
|
||||
fi
|
||||
cat > /tmp/workflow-sync.md <<EOF
|
||||
Тонкие вызывающие workflow в \`main\` и \`dev\` различаются. Прогон: $RUN_URL
|
||||
|
||||
Тонкий файл исполняется из ветки по умолчанию, поэтому правку триггеров, входов или прав нужно зеркалить в \`main\` (PROCESS.md §10.4, #623). На ветках задач это предупреждение (#700), на push в \`dev\` — красный preflight, пока зеркало не выровнено.
|
||||
EOF
|
||||
gh issue create --repo "$REPO" --title "$marker тонкие workflow в main и dev различаются" \
|
||||
--label infra --label process --body-file /tmp/workflow-sync.md \
|
||||
|| echo "::warning::issue о расхождении зеркала не заведено"
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
|
||||
@@ -149,9 +196,14 @@ jobs:
|
||||
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
|
||||
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
|
||||
# в красноте навсегда.
|
||||
#
|
||||
# #703: то же на `main`. Stable-промоушен — пуш в `main` SHA, уже
|
||||
# судимого на `dev`; с `event.before` (прошлый stable) провенанс и
|
||||
# процессный гейт судили бы всю бета-линию заново. Прогоны читаются с
|
||||
# обеих интеграционных веток, сам HEAD засчитывает только успешный.
|
||||
- name: "База диапазона: последний доказанно зелёный предок"
|
||||
id: range
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
@@ -161,11 +213,14 @@ jobs:
|
||||
# `status=completed`, а не `success`: гейтам диапазона нужен факт
|
||||
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
|
||||
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch=dev -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
for branch in dev main; do
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch="$branch" -f status=completed -F per_page=100 \
|
||||
> "/tmp/validate-runs-$branch.json" || echo '{}' > "/tmp/validate-runs-$branch.json"
|
||||
done
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
--fallback="$FALLBACK" \
|
||||
--runs=/tmp/validate-runs-dev.json --runs=/tmp/validate-runs-main.json
|
||||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||||
id: provenance
|
||||
continue-on-error: true
|
||||
@@ -220,6 +275,7 @@ jobs:
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
ACTION_PINS: ${{ steps.action_pins.outcome }}
|
||||
REVIEWS_INDEX: ${{ steps.reviews_index.outcome }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
@@ -231,9 +287,25 @@ jobs:
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
# #700: причина, к которой ветка задачи отношения не имеет, — в сводку
|
||||
# предупреждением; на dev, кандидате и релизе она красит как прежде.
|
||||
advise() {
|
||||
if [ "$2" = "success" ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "::warning::$1 ($2) — на ветке задачи предупреждение (#700)"
|
||||
echo "- $1: $2 — предупреждение, чинит релиз-менеджер (#700)" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
task_branch=false
|
||||
case "$REF" in refs/heads/issue/*) task_branch=true ;; esac
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
if [ "$task_branch" = "true" ]; then
|
||||
advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
else
|
||||
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
fi
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
check "пины сторонних Actions" "$ACTION_PINS"
|
||||
@@ -275,9 +347,11 @@ jobs:
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345). Теги — нужны:
|
||||
# они граница проверенного материала для `range_base` (#703), а checkout
|
||||
# по умолчанию передаёт `git fetch --no-tags`.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
with: { fetch-depth: 0, filter: 'blob:none', fetch-tags: true }
|
||||
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
|
||||
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
|
||||
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
|
||||
@@ -294,7 +368,11 @@ jobs:
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
# #751: без pipefail упавший classify-changes оставлял heavy пустым —
|
||||
# тяжёлые job молча пропускались, а job changes зеленела.
|
||||
run: |
|
||||
set -o pipefail
|
||||
node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
@@ -316,9 +394,15 @@ jobs:
|
||||
if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then
|
||||
# На интеграционных ветках классифицировать нечего (всё true), но
|
||||
# база диапазона нужна гейту «новый код не добавляет any» в job
|
||||
# frontend (#388, #619).
|
||||
# frontend (#388, #619). #703: прогоны обеих интеграционных веток —
|
||||
# на `main` приходит SHA, уже судимый на `dev`.
|
||||
other=dev; [ "$BRANCH" = "dev" ] && other=main
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch="$other" -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs-other.json || echo '{}' > /tmp/validate-runs-other.json
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
--name=range_base --fallback="$FALLBACK" \
|
||||
--runs=/tmp/validate-runs.json --runs=/tmp/validate-runs-other.json
|
||||
exit 0
|
||||
fi
|
||||
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
|
||||
@@ -594,6 +678,10 @@ jobs:
|
||||
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
|
||||
# материале; PR берёт её сам. За 08–09.09 на промежуточных пушах она стоила
|
||||
# 48 из 56 часов job-минут и в основном отменялась следующим пушем. С #601
|
||||
# С #709 job не запрашивает никто: мутанты в разработке не гоняются, весь
|
||||
# реестр проверяет ночь (mutation-gate.yml). Job оставлен до отдельной уборки
|
||||
# вместе с контрактом имён job (#622); `mutants_requested` всегда false.
|
||||
# Ниже — история до #709.
|
||||
# её не запрашивают ни `full=true`, ни кандидат беты (`Release:`): к бете
|
||||
# каждая задача уже прогнана мутантами на ревью и на слитом кандидате, а
|
||||
# ночь покрыта полным реестром. Когда мутанты запрошены, job бежит даже при
|
||||
|
||||
@@ -29,7 +29,9 @@ leak interactions into View. For work that changes visible behaviour, also read
|
||||
route and `test/entry-cost.test.mjs` keeps this list equal to its routes:
|
||||
|
||||
- author (analysis, spec, implementation, infrastructure): `docs/SCOPE.md` →
|
||||
`AGENTS.md` → `docs/process/AUTHOR.md` → `docs/STATUS.md`;
|
||||
`AGENTS.md` → `docs/process/AUTHOR.md`, then the task packet
|
||||
(`node scripts/task-packet.mjs --issue NN`); the status snapshot
|
||||
(docs/STATUS.md) only when resuming a session or preparing a release;
|
||||
- reviewer (spec or code): `docs/SCOPE.md` → `AGENTS.md` →
|
||||
`docs/process/REVIEWER.md`, then the issue body and its comments;
|
||||
- changing the pipeline, the gates or the process itself: `docs/SCOPE.md` →
|
||||
@@ -61,7 +63,11 @@ gh issue view <NN> --repo Matysh/houseplan-card --json number,state,labels
|
||||
|
||||
The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anything
|
||||
else — refuse and say why. "Issue #83 is in `S2-analysis`, code is off limits.
|
||||
Start with the spec?" is the correct answer, not a smaller patch.
|
||||
Start with the spec?" is the correct answer, not a smaller patch. The one
|
||||
exception is a local, unpushed draft on `track:ask` while the spec is in
|
||||
`S4-spec-review`, each commit carrying the `Spec-Draft` trailer from the task
|
||||
packet; it reaches the branch only after a green spec review (`PROCESS.md`
|
||||
§11.8).
|
||||
|
||||
GitHub Issues are the canonical task records and the **labels** are the status
|
||||
(`PROCESS.md` §9); when repository documentation disagrees with an issue, the
|
||||
@@ -78,12 +84,20 @@ committing.
|
||||
default: up to three AC in the issue body, no spec review, `S2` → `S5`. `ship` is a
|
||||
one-sentence change within fixed limits, `S1` → `S5`. `ask` is the full route with
|
||||
a spec review. Any agent may raise a track with a reason; only the owner lowers it.
|
||||
A label is a proposal until the owner confirms it with a comment line
|
||||
`Трек: <ship|show|ask> — решение владельца`; an agent never writes that line.
|
||||
Several track labels at once read as the strictest. A risky changed hunk
|
||||
(geometry, touch, migration, devices, perf, a new UX key) raises an unconfirmed
|
||||
`ship` to `show` at `S7`; on `show` it is a question to the reviewer (#707).
|
||||
`small` and `trivial` read as `show`. An **infrastructure** task — not a single
|
||||
class A file — skips analysis and spec and enters at `S7-code-review`
|
||||
(`PROCESS.md` §1). Every change is code-reviewed; on `ship` the review moves to a
|
||||
batch review of the beta range before the tag (`ship-review.yml`, `PROCESS.md`
|
||||
§11.7). The review pipeline prices each round by track (§10.4): diff mutants
|
||||
only on `ask` or with `ci:mutants`; a rebase before review only on `ask` or when
|
||||
batch review (`ship-review.yml`, `PROCESS.md` §11.7): nightly
|
||||
(`SHIP-REVIEW-<base>-dev-<sha12>.md`), and before the beta tag whatever the night
|
||||
did not read (`SHIP-REVIEW-<tag>.md`). The review pipeline prices each round by
|
||||
track (§10.4): no mutants run
|
||||
during development on any track — the whole registry runs nightly (#709); a
|
||||
rebase before review only on `ask` or when
|
||||
the branch does not merge cleanly into `dev`. Review checks scope, risks and the
|
||||
evidence from executed tests, but does not replace executing them.
|
||||
|
||||
@@ -100,8 +114,9 @@ batched comment with a proposed default for each question and `blocked` on top o
|
||||
## Commits and branches
|
||||
|
||||
Hooks install themselves on `npm ci` (`prepare` → `scripts/install-hooks.mjs`);
|
||||
`git config core.hooksPath` must print `.githooks`. Every non-merge commit
|
||||
carries **terminal** trailers:
|
||||
`git config core.hooksPath` must print `.githooks`. Every non-merge commit that
|
||||
touches anything outside class C (docs) carries **terminal** trailers; a
|
||||
docs-only commit needs none (`PROCESS.md` §3 п.10, #701):
|
||||
|
||||
```text
|
||||
Issue: #123
|
||||
@@ -143,9 +158,12 @@ fresh checkout. A worktree works only on the machine that created it — its
|
||||
|
||||
## Handoff and the verdict
|
||||
|
||||
Start a task from its packet: `node scripts/task-packet.mjs --issue NN` (status,
|
||||
track, what the status permits, the branch against `dev`, the previous verdict
|
||||
and the unwitnessed AC; it writes nothing). The local gate is
|
||||
Start a task from its packet: `node scripts/task-packet.mjs --issue NN` (status;
|
||||
track with its basis, cycle limit and rebase policy; what the status permits;
|
||||
the branch against `dev` and the next step — no rebase a clean `show`/`ship`
|
||||
merge does not need; risk by changed hunks and what it means on this track; the
|
||||
required checks, each with its reason; changelog and visual evidence; the
|
||||
previous verdict and the unwitnessed AC; it writes nothing). The local gate is
|
||||
`npm run gate:small` (`docs/TESTING.md` › Локальный набор перед пушем); run the
|
||||
smokes named in the AC before `S7-code-review`. **"Verified" without a named
|
||||
command and its result is not evidence.** Comment formats — claim, handoff,
|
||||
@@ -166,7 +184,7 @@ set.
|
||||
|
||||
| Now reads | What happened | What you do |
|
||||
|---|---|---|
|
||||
| `S5-ready` | the spec is accepted | write the code |
|
||||
| `S5-ready` | the spec is accepted | write the code (a draft from `S4`: rebase onto `dev`, check, push — §11.8) |
|
||||
| `S3-spec` | the spec came back | read the verdict, revise, re-apply `S4-spec-review` |
|
||||
| `S6-in-progress` | the code came back | revise, re-apply `S7-code-review` — **or**, if the verdict was green and only the merge conflicted, just rebase and re-apply. The comment says which |
|
||||
| `S8-merged` | accepted and already in `dev` | nothing |
|
||||
|
||||
@@ -94,12 +94,11 @@ issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
```bash
|
||||
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
npm ci # frontend toolchain
|
||||
npm ci # frontend toolchain; `prepare` installs .githooks
|
||||
npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
npm run build # tsc + rollup → dist/houseplan-card.js
|
||||
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
|
||||
npm install # also installs .githooks through the prepare script
|
||||
pip install -r tests_backend/requirements.txt && python -m pytest tests_backend -q # CI pins, HA harness included
|
||||
```
|
||||
|
||||
### Why `--filter=blob:none` (#345)
|
||||
@@ -127,7 +126,9 @@ Linux CI or WSL (`bash scripts/wsl-setup.sh --verify`). Without an importable
|
||||
## Ground rules
|
||||
|
||||
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
`docs/ARCHITECTURE.md` for changes to the data model, WS API or coordinate
|
||||
system; `docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new
|
||||
gotchas. A docs-only commit needs no trailers (`PROCESS.md` §3 п.10).
|
||||
- Every UI string goes through `src/i18n/<lang>.json`; follow the
|
||||
[Translations](#translations) flow for registry and backend parity.
|
||||
- The committed bundle changes only in a release candidate: `npm run
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
|
||||
> входят в общий флоу сразу на `S7-code-review`.
|
||||
>
|
||||
> **Область действия:** обязателен для владельца и для любого агента. Читается
|
||||
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
|
||||
> **Область действия:** обязателен для владельца и для любого агента. Целиком
|
||||
> его читает тот, кто правит конвейер, гейты или сам процесс, — сразу после
|
||||
> `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`; автор и ревьюер входят через
|
||||
> ролевые конспекты ниже и открывают раздел канона по ссылке (#634, #701). Живёт в
|
||||
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
@@ -24,8 +26,9 @@
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
|
||||
> процесса в репозитории; `AGENTS.md` — его короткое обязательное резюме, а
|
||||
> внешний `CODEX-RUNBOOK.md` — только маршрутизатор к канону и историческим
|
||||
> инструкциям. Текущие версии, состояние конкретных issue, runtime pins и списки
|
||||
> внешние `CODEX-RUNBOOK.md` и `CLAUDE.md` папки владельца — только маршрутизаторы
|
||||
> к канону и историческим инструкциям: правил в них нет, и в замер цены входа
|
||||
> (`scripts/entry-cost.mjs`) они не входят — CI их не видит (#701). Текущие версии, состояние конкретных issue, runtime pins и списки
|
||||
> jobs не копируются в производную прозу: они читаются из своих исполняемых
|
||||
> источников.
|
||||
> При расхождении этого документа с `.github/workflows/*.yml` и `scripts/*`
|
||||
@@ -40,7 +43,8 @@
|
||||
|
||||
**Изменение продуктового кода без issue запрещено.** Код меняется только тогда,
|
||||
когда issue существует и находится в статусе «Готово к разработке» или дальше.
|
||||
Исключения — только §11, и каждое оставляет след.
|
||||
Исключения — только §11 (в том числе черновик `track:ask` во время ревью ТЗ,
|
||||
§11.8), и каждое оставляет след.
|
||||
|
||||
Правило работает лишь при точной границе «продуктового кода», иначе спор
|
||||
переносится на границу:
|
||||
@@ -169,13 +173,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
правится. «Оставили в тексте ревью» не считается закрытием ни для одной
|
||||
(решение владельца 2026-08-19, #202: отдельный issue дороже правки на месте).
|
||||
Low либо правится, либо снимается решением ревьюера с записью.
|
||||
- **Черновик автора** (§11.8) ревьюер ТЗ не читает и доводом не считает:
|
||||
судится тело issue.
|
||||
- **Выход:** «Готово к разработке» либо возврат в «ТЗ в работе» — не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
### 2.5 Готово к разработке (DoR)
|
||||
|
||||
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
|
||||
Все пункты обязательны:
|
||||
Не работа, а **очередь**: единственный статус, из которого можно трогать код
|
||||
в ветку задачи; локальный черновик `track:ask` — §11.8. Все пункты обязательны:
|
||||
|
||||
- ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте.
|
||||
На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом
|
||||
@@ -200,9 +206,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Занятие (claim):** назначить себя, поставить метку, комментарий
|
||||
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
|
||||
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
|
||||
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
|
||||
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
|
||||
`Issue: #<NN>` и `User-Visible: yes|no`.
|
||||
**3** одновременно на цикл релиза, не более **2** в «Код-ревью». Черновик
|
||||
`track:ask` во время ревью ТЗ (§11.8) занимает тот же слот: у исполнителя
|
||||
одновременно одна задача в `S6-in-progress` или с черновиком.
|
||||
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт трейлеры
|
||||
`Issue: #<NN>` и `User-Visible: yes|no`; коммит только из документации — без
|
||||
трейлеров (§3 п.10, #701).
|
||||
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
|
||||
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
|
||||
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
|
||||
@@ -241,28 +250,34 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
|
||||
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
|
||||
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
|
||||
команда или имя теста) **· чем краснеет** — мутация, снятая защита или
|
||||
отрицательная проба, с результатом прогона. Пустой третий столбец — находка
|
||||
Medium, а не примечание.
|
||||
команда или имя теста) **· чем краснеет** — мутант в реестре или
|
||||
отрицательный случай в самом тесте; прогон мутанта даёт ночь (#709). Пустой
|
||||
третий столбец — находка Medium, а не примечание.
|
||||
|
||||
«Тест умеет падать» без названной мутации и её вывода доказательством не
|
||||
является. Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
|
||||
«Тест умеет падать» без названной мутации доказательством не является; её
|
||||
поимку подтверждает ночной прогон (#709). Аудит v1.71.0-beta.1 нашёл пять контрактов #51 и #423, где тест
|
||||
оставался зелёным на снятой защите; все пять прошли код-ревью как доказанные,
|
||||
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
|
||||
то, чего они не проверяли (#430).
|
||||
|
||||
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
Мутант в реестре `scripts/mutation-registry.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden). Для
|
||||
чистых юнитов достаточно отрицательного случая в самом тесте.
|
||||
|
||||
**Мутант пишется, но в разработке не гоняется** — ни локально, ни в CI, ни
|
||||
ревьюером (#709, решение владельца 2026-09-29). Мутанты проверяют тесты, а не
|
||||
продукт, и их цена — десятки минут на задачу. Якоря реестра сверяет статический
|
||||
`mutation-gate --check`. Поимку проверяет только ночной полный прогон
|
||||
(`mutation-gate.yml`, #513); выживший мутант ночью заводит issue (#472).
|
||||
|
||||
Новый мутант с browser-smoke guard допустим только когда инвариант нельзя
|
||||
доказать без браузера: `because` обязан назвать конкретную зависимость от
|
||||
DOM/CSS paint, измеренной геометрии, trusted pointer/lifecycle или browser
|
||||
wall-time, а id — попасть в размеченный реестр
|
||||
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
|
||||
показывает число browser guards против лимита 200, краснеет при росте выше
|
||||
лимита и предупреждает о любом id без browser-обоснования; ревьюер проверяет
|
||||
показывает число browser guards против ориентира 200 и предупреждает — не
|
||||
краснеет (#699) — при росте выше него и о любом id без browser-обоснования;
|
||||
удалять чужой мутант ради числа не нужно; ревьюер проверяет
|
||||
не только наличие строки, но и невозможность более дешёвого `node --test`.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
@@ -299,9 +314,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
(`scripts/monolith-metrics.mjs`: делегаты, члены порта, `host.`, приватные
|
||||
члены порта и харнесса, байты `dist/`), база — `scripts/monolith-baseline.json`;
|
||||
гейт `npm run lint:unused` (в `gate:small` и Validate после сборки) красит
|
||||
рост любого из них и любой мёртвый код по `noUnusedLocals` вне порта и
|
||||
харнесса. Снижение фиксируется тем же коммитом
|
||||
(`node scripts/unused-locals-gate.mjs --update`); рост — только с записью в
|
||||
рост любого из них сверх полосы (#699: делегаты, члены и приватные порта и
|
||||
харнесса — 5, `host.` — 25, байты `dist/` — 2 000) и любой мёртвый код по
|
||||
`noUnusedLocals` вне порта и харнесса. Снижение ветку не красит — базу до
|
||||
факта опускает бета (§8, «Храповики»); рост сверх полосы — только с записью в
|
||||
issue задачи и правкой базы в том же коммите.
|
||||
- **Смок входит в сценарий через публичную поверхность (#629).** DOM с
|
||||
контрактными хуками, события HA и фикстуры, тестовый фасад `window.__hpTest`
|
||||
@@ -385,16 +401,26 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
fast-forward (`--commit-if-stale`, коммит класса C) — и публикацией документа ревью ТЗ
|
||||
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
|
||||
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
|
||||
конфликтуют на нём по построению. Руками не правится. Конфликт ребейза, в котором **все** пути —
|
||||
конфликтуют на нём по построению. Сам `INDEX.md` руками не правится никогда. Конфликт ребейза, в котором **все** пути —
|
||||
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
|
||||
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
|
||||
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Шаг Validate
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Ещё два общих
|
||||
файла не конфликтуют по смыслу (#698): записи ченджлогов в `## Unreleased`
|
||||
объединяет встроенный драйвер `merge=union` (`.gitattributes`), а на конфликте в
|
||||
`scripts/monolith-baseline.json` ребейз берёт сторону `dev` — числа
|
||||
объединённого дерева судит гейт связности монолита (`npm run lint:unused`) на
|
||||
Validate кандидата. Сегодня пять чисел исходника он судит точно, байты `dist/` —
|
||||
с полосой 2 000 Б; поэтому сторона `dev` в базе краснеет, если задача сама
|
||||
меняла эти числа, — это прежний возврат автору, только после Validate, а не до
|
||||
ревью. Полоса у всех шести чисел — #699. Ни ченджлоги, ни база не входят в
|
||||
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе. Шаг Validate
|
||||
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
|
||||
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
|
||||
Правка `docs/reviews/`
|
||||
руками — перенос в `legacy/`, удаление — сопровождается
|
||||
`node scripts/reviews-index.mjs` в том же коммите. Прежде чем брать
|
||||
Ручная правка каталога
|
||||
`docs/reviews/` — перенос документов в `legacy/`, удаление — сопровождается
|
||||
пересборкой индекса `node scripts/reviews-index.mjs` в том же коммите: индекс
|
||||
меняет генератор, а не рука. Прежде чем брать
|
||||
задачу по подсистеме, стоит прочитать её строки в индексе: что находили и чем
|
||||
закрывали — там, а не в тысяче файлов. Уроки, пережившие свою задачу,
|
||||
собираются в `docs/LESSONS.md` с датой и ссылкой на источник.
|
||||
@@ -438,9 +464,12 @@ dev, ни при публикации документа код-ревью: ин
|
||||
Продолжение черновика владельца. Каждое правило проверяемо — глазами или машиной.
|
||||
|
||||
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
|
||||
разработке» или дальше.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
|
||||
пронумерованных AC с указанием доказательства и назначенного исполнителя.
|
||||
разработке» или дальше. Исключение — локальный черновик на `track:ask` во
|
||||
время ревью ТЗ (§11.8): в ветку он попадает только после зелёного ревью ТЗ.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ в объёме трека
|
||||
(§5: на `track:ask` — с зелёным ревью ТЗ; на `show` — до трёх AC; на `ship` —
|
||||
строка «что меняется и чем проверить»), доказательства для каждого AC и
|
||||
назначенного исполнителя. Инфраструктурная задача ТЗ не пишет (§1).
|
||||
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
|
||||
комментарием с именем ветки. У одного исполнителя одновременно не более одного
|
||||
issue в разработке.
|
||||
@@ -460,14 +489,17 @@ dev, ни при публикации документа код-ревью: ин
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
|
||||
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
|
||||
том же коммите. Документационный коммит — только файлы класса C — трейлеров
|
||||
не требует (#701, как `skip issue` у CPython); хук `commit-msg`, CI и
|
||||
`process-gate` судят одинаково. Коммит класса D несёт `Release:` (п.12). После `cherry-pick -x` служебная строка `(cherry picked
|
||||
from ...)` должна оставаться выше финального блока трейлеров: перед push
|
||||
проверяем порядок через `git show -s --format=full HEAD`.
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
принятие эталонов с доказательством ревью: URL Linux CI run либо хеш
|
||||
аттестованного WSL-артефакта.
|
||||
принятие эталонов с доказательством ревью: трейлер `Release:` и ровно один
|
||||
источник — URL Linux CI run (`Baseline-Reviewed:`) либо хеш аттестованного
|
||||
WSL-артефакта (`Baseline-Reviewed-Local:`), §10.1.
|
||||
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
|
||||
полному Linux-артефакту: либо GitHub CI, либо `npm run golden:wsl:capture` в
|
||||
WSL/ext4 с clean опубликованным SHA и машинно-проверяемым паспортом. Второй
|
||||
@@ -507,11 +539,22 @@ dev, ни при публикации документа код-ревью: ин
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
чем циклов, поэтому порог проверки №7 в `scripts/process-gate.mjs`
|
||||
(`REVIEW_DOC_LIMIT` — шесть документов одного вида на issue) выше лимита
|
||||
циклов: четыре цикла плюс два ребейза.
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
- **Бюджет этапа один на все треки задачи** (#726). Повышение трека меняет
|
||||
лимит, а не счёт: блокирующие вердикты прежних заходов остаются в бюджете
|
||||
этапа, и после `show` → `ask` код-ревью продолжает тот же счёт с лимитом 4 —
|
||||
на всех треках вместе не больше 4 циклов. Ревью ТЗ между ними — свой этап и
|
||||
свой бюджет.
|
||||
- **Вердикт, исчерпавший бюджет, сразу ставит `review-4`** (#726): шаг решения
|
||||
по вердикту возвращает задачу (`S6-in-progress` или `S3-spec`) с меткой и
|
||||
комментарием «Лимит циклов ревью исчерпан» — счёт, учтённые вердикты и
|
||||
варианты ниже. Прежде об исчерпании говорил только следующий `S7`, после
|
||||
правки, которую уже никто не прочтёт; его проверка осталась страховкой.
|
||||
- **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу,
|
||||
решение одно из трёх:
|
||||
1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с
|
||||
@@ -535,15 +578,26 @@ dev, ни при публикации документа код-ревью: ин
|
||||
короткий (`trivial`) — 41 мин и 5 событий. Трек определялся формальными
|
||||
критериями, и у владельца не было метки, чтобы задать его самому.
|
||||
|
||||
Эффект треков меряет еженедельный отчёт `process-metrics.yml` (#728).
|
||||
|
||||
**Трек задаёт метка `track:ship`, `track:show` или `track:ask`. Метка владельца
|
||||
главнее критериев:** критерии ниже — подсказка аналитика, а не приговор.
|
||||
|
||||
- Аналитик предлагает трек в «Оценке» (§7.2) и ставит метку. По умолчанию —
|
||||
`track:show`.
|
||||
- Владелец ставит или меняет любую из трёх меток в любой момент; его метка
|
||||
окончательна.
|
||||
- Владелец ставит или меняет любую из трёх меток в любой момент. Трек он
|
||||
подтверждает строкой `Трек: <ship|show|ask> — решение владельца` в начале
|
||||
строки своего комментария (тире любое, регистр не важен, #707); окончательна
|
||||
метка, подтверждённая строкой владельца. Метка без такой строки — предложение:
|
||||
аналитика, агента или конвейера. Действует самая поздняя строка владельца и
|
||||
только для текущего трека. Агент эту строку не пишет никогда: агенты и
|
||||
конвейер действуют от учётной записи владельца, и по автору события их не
|
||||
отличить.
|
||||
- Повысить трек (`ship` → `show` → `ask`) вправе любой агент, с причиной в
|
||||
комментарии. Понизить — только владелец.
|
||||
- Несколько трековых меток сразу — дефект разметки: действует строжайшая
|
||||
(`ask` > `show` > `ship`), пакет задачи и сводка конвейера печатают
|
||||
предупреждение (#707).
|
||||
- Трек пересматривается, когда владелец снял усложнявший пункт (#688):
|
||||
аналитик предлагает понижение, решает владелец.
|
||||
|
||||
@@ -563,6 +617,35 @@ dev, ни при публикации документа код-ревью: ин
|
||||
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
|
||||
диапазона перед бетой (§11.7).
|
||||
|
||||
**Проверка риска по изменённым участкам** (#707). Рамки считают строки и файлы,
|
||||
но не видят, что именно тронуто: обработчик `pointerdown` в двенадцать строк
|
||||
проходит их так же, как опечатка. Поэтому на `S7` конвейер судит и изменённые
|
||||
строки файлов класса A — дифф `git diff --unified=0` от merge-base с `dev`
|
||||
(`scripts/change-risk.mjs`). Правило «участок» — любая изменённая строка кода в
|
||||
файле из списка, правило «токен» — токен в изменённой строке; пустые строки,
|
||||
комментарии и чистое переименование риска не дают, а также строки импорта и
|
||||
строки только типов TypeScript (кроме файлов участка `migration`: типы конфига —
|
||||
контракт, #755); удаление кода — даёт.
|
||||
Классы: `geometry`, `touch`, `migration`, `devices`, `perf`, `ux` и `visual`
|
||||
(области `render` — путь отрисовки плана — и `ui` — стили интерфейса). Таблица
|
||||
путей и токенов — эвристика и живёт в скрипте.
|
||||
|
||||
- `ship` с любым классом, кроме `visual`, без подтверждения владельца конвейер
|
||||
повышает до `show` тем же путём, что при выходе за рамки: комментарий с
|
||||
классами и доказательствами `путь:строка`. Подтверждённый владельцем `ship`
|
||||
остаётся, риск уходит в пакетное ревью (§11.7). `visual` ship не повышает:
|
||||
CSS-мелочь — законный ship. Выход за рамки повышает трек и при
|
||||
подтверждённом `ship`, как прежде.
|
||||
- На `show` риск — вопрос ревьюеру, сам по себе трек он не меняет: по каждому
|
||||
классу ревьюер называет документ или AC, где поведение уже зафиксировано; не
|
||||
нашёл — вердикт с `route: reclassify` и названным критерием (#726, ниже).
|
||||
`show`, подтверждённый владельцем, конвейер не повышает: `blocked` и вопрос
|
||||
владельцу с вариантом по умолчанию «повысить до `ask`». На `ask` ревьюер
|
||||
сверяет, что каждый класс покрыт AC ТЗ.
|
||||
- Автор видит те же классы и их следствие в пакете задачи
|
||||
(`node scripts/task-packet.mjs --issue NN`) до `S7`: пакет и конвейер читают
|
||||
одно правило.
|
||||
|
||||
**Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу:
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
@@ -582,7 +665,21 @@ dev, ни при публикации документа код-ревью: ин
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение. Если по
|
||||
ходу выясняется, что решать есть что, трек повышается до `ask`: issue уходит в
|
||||
`S3-spec` и получает полное ТЗ в теле issue по §7.1. Это не провал, а ранняя
|
||||
диагностика.
|
||||
диагностика. Проверка риска по участкам (выше) даёт признаку механическую опору:
|
||||
рискованный участок повышает `ship` и требует от ревью `show` ссылки на
|
||||
зафиксированное поведение.
|
||||
|
||||
На код-ревью `show` повышение исполняет конвейер (#726). Ревьюер возвращает не
|
||||
зелёный вердикт с `route: reclassify` и `criterion` — идентификатором
|
||||
невыполненного пункта подсказки выше: `complexity`, `surfaces`, `migration`,
|
||||
`ux-contract`, `perf-touch`, `undocumented`. Шаг решения по вердикту ставит
|
||||
`track:ask`, снимает `track:show` и переводит задачу в `S3-spec` комментарием:
|
||||
критерий словами, документы код-ревью, бюджет. Код остаётся в ветке, полное ТЗ
|
||||
пишется в теле issue, код класса A не пушится до `S5`; бюджет код-ревью не
|
||||
обнуляется (§4). Подтверждённый владельцем `show` конвейер не повышает: задача
|
||||
получает `blocked` и вопрос владельцу с вариантом по умолчанию «повысить до
|
||||
`ask`». `reclassify` без критерия из списка, на `ask` или на ревью ТЗ —
|
||||
обычный возврат с пометкой в комментарии.
|
||||
|
||||
**Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры
|
||||
коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега
|
||||
@@ -598,10 +695,17 @@ dev, ни при публикации документа код-ревью: ин
|
||||
|---|---|
|
||||
| `ci:full` | полный Validate на ветке задачи |
|
||||
| `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче |
|
||||
| `ci:mutants` | мутанты по диффу на кандидате ревью и слияния |
|
||||
|
||||
Как конвейер читает метки, описывает §10.4: `ci:mutants` (#696), `ci:full` и
|
||||
`ci:golden` — dispatch Validate с `full=true` на материале ревью (#697).
|
||||
Как конвейер читает метки, описывает §10.4: `ci:full` и `ci:golden` — dispatch
|
||||
Validate с `full=true` на материале ревью (#697). Метка `ci:mutants` снята
|
||||
(#709): мутанты в разработке не гоняются ни на каком треке.
|
||||
|
||||
**`ci:golden` ставится по сигналу, а не на глаз** (#707). Сигнал — визуальный
|
||||
риск в пути отрисовки плана (класс `visual`, область `render`, §5): пакет задачи
|
||||
рекомендует `ci:golden`, если сдвиг кадров намерен, а заметка ревьюеру без метки
|
||||
просит либо её, либо запись в «чего не проверял». Правка стилей интерфейса
|
||||
(`ui`), только комментариев или только тестов сигнала не даёт. Риск сам полного
|
||||
набора не включает, `visual` тоже: `full=true` дают только метки.
|
||||
|
||||
**Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая
|
||||
задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без
|
||||
@@ -619,7 +723,7 @@ dev, ни при публикации документа код-ревью: ин
|
||||
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
|
||||
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
|
||||
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код; принимать golden — кроме сдвига своей задачи с меткой `ci:golden` по §3 п.13 (§8, #697) |
|
||||
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
|
||||
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
|
||||
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
|
||||
@@ -668,6 +772,11 @@ issue #NN
|
||||
которое не может ответить на эти два вопроса, описывает работу, а не изменение
|
||||
продукта.
|
||||
|
||||
**Дефект растра, резкости или композитинга** — на любом треке (#690): до кода
|
||||
нужен свидетель, красный на старом коде именно по симптому владельца, а в AC —
|
||||
подтверждение владельца в реальном GPU-браузере. Headless-доказательства мало:
|
||||
#685 доказал правку в headless Linux и не устранил симптом, его откатил #689.
|
||||
|
||||
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
|
||||
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
|
||||
|
||||
@@ -703,6 +812,8 @@ issue #NN
|
||||
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Черновик** (§11.8): `Черновик: <роль> · сессия <id> · локальная ветка
|
||||
issue/NN-slug · Spec-Draft sha256:<первые 12>`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
- **Вердикт ревью:** `Вердикт: зелёный/жёлтый/красный · заход r<N> ·
|
||||
@@ -712,6 +823,13 @@ issue #NN
|
||||
Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты
|
||||
его не тратят, поэтому заход и K расходятся, #227)
|
||||
- **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>`
|
||||
- **Маршрут вердикта** (пишет конвейер, #726): первой строкой «**Ревью show:
|
||||
решать есть что — трек повышен до `track:ask`.**» либо «**Ревью show: решать
|
||||
есть что — вопрос владельцу.**», последней — машинная
|
||||
`<!-- hp:route <reclassify|owner-question> criterion=<id> -->`. Исчерпание
|
||||
бюджета — первой строкой `Лимит циклов ревью исчерпан`, дальше счёт,
|
||||
учтённые вердикты и варианты §4. Маршрут, не применённый к вердикту, —
|
||||
строка «маршрут reclassify не применён: <причина>».
|
||||
|
||||
**Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору;
|
||||
разница между ними содержательна для человека, но не для маршрута. Первая
|
||||
@@ -724,9 +842,27 @@ issue #NN
|
||||
## 8. Гейты
|
||||
|
||||
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
|
||||
покрывающий изменённые поверхности (действующее правило владельца):
|
||||
покрывающий изменённые поверхности (действующее правило владельца).
|
||||
Обязательную часть исполняет одна команда, `npm run gate:small`
|
||||
(`scripts/gate-small.mjs` — единственный источник её состава, #701): сборка с
|
||||
typecheck, юниты, целостность и бюджет бандла, `no-new-any`, запрет синхронного
|
||||
чтения layout в render, запрет записи смоков в приватное состояние,
|
||||
`lint:unused` и вывод `smoke-select`. Команды ниже — то же самое по отдельности
|
||||
плюс то, что по диффу и AC:
|
||||
|
||||
**Цена `ship` и `show` — без добровольных надбавок** (#709, разбор #693: из 57
|
||||
минут однострочного фикса 36 ушли на необязательное). Сверх `gate:small` и
|
||||
названного в ТЗ автор ничего не прогоняет:
|
||||
|
||||
- мутанты не гоняются (§2.7);
|
||||
- на пункт ТЗ — одно доказательство, дублей вида «смок и юнит на одно и то же» нет;
|
||||
- `gate:small -- --smokes` на `ship` не нужен, на `show` — только смоки из вывода
|
||||
`smoke-select` и названные в AC;
|
||||
- флак, найденный по ходу, — отдельное issue одной записью: что упало, где,
|
||||
как часто. Расследование в рамках задачи не ведётся.
|
||||
|
||||
```
|
||||
npm run gate:small # обязательная часть одной командой
|
||||
npx tsc --noEmit
|
||||
npm test
|
||||
npm run build && node scripts/bundle-policy.mjs --verify HEAD
|
||||
@@ -762,7 +898,10 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
|
||||
диффу, печатает
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
прикладывается к ревью вместе с решением по каждой строке; если связь не
|
||||
доказана, он печатает визуальный минимум — восемь смоков режимов, слоёв и
|
||||
отрисовки, локально меньше минуты, — и автор гоняет его до `S7`, `gate:small --
|
||||
--smokes` делает это сам, #690), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml`
|
||||
@@ -770,7 +909,8 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
(`workflow_dispatch`, только артефакт) с приёмкой вручную: `npm run docs:accept --
|
||||
--reviewed --from=<распакованный артефакт>` (#246). Ручной путь остаётся
|
||||
релиз-менеджеру, если бот недоступен. Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
|
||||
кадре, и набор из «не того» браузера переписывает все кадры (их число — в
|
||||
`demo/docs/screenshots.mjs`) без единого
|
||||
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота
|
||||
проверяет релиз-менеджер, коммит ручной приёмки делает человек.
|
||||
@@ -798,7 +938,11 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
(`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите).
|
||||
Изменившийся кадр или сцена принимается, только если назван во входах
|
||||
workflow; необъявленная разница — отказ с перечнем. Коммит проверяет
|
||||
релиз-менеджер;
|
||||
релиз-менеджер. Запуск — `gh workflow run beta-derived.yml --ref dev -f
|
||||
tag=vX.Y.Z-beta.N` (сдвиги — `-f docs_expect_change=…`, эталоны — `-f
|
||||
golden_run=<id Validate на dev>` с `golden_expect_change`/`golden_expect_new`)
|
||||
или кнопка в Actions: тонкий вызывающий лежит и в `main`, тело
|
||||
`_beta-derived.yml` читается из `dev` (§10.4, #716);
|
||||
- **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер
|
||||
прогоняет полный набор на материале ревью, и сдвинутые кадры задача
|
||||
принимает сама — по §3 п.13;
|
||||
@@ -809,6 +953,22 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
|
||||
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
|
||||
|
||||
**Храповики — полоса над потолком беты** (#699, решение владельца 2026-09-28).
|
||||
Строки двух ядер (`test/core-file-budget.test.mjs`), gzip стартового и ленивых
|
||||
графов (`scripts/bundle-budget.mjs`) и числа связности монолита
|
||||
(`scripts/monolith-baseline.json`) судятся одним правилом: потолок — факт
|
||||
последней беты; ветка задачи краснеет, только если вышла выше потолка больше
|
||||
чем на полосу (ядро — 50 строк, графы и `dist/` — 2 000 Б, числа монолита — по
|
||||
`METRIC_BANDS`), а снижение её не красит. Прежде храповики были двусторонними с
|
||||
нулевым запасом: параллельные задачи конфликтовали на общих числах, пересчитывали
|
||||
их после ребейза и упирались в потолок, потому что перед ними влили чужую (#689
|
||||
после #691). Вторая сторона храповика живёт на бете: релиз-менеджер на
|
||||
кандидате выполняет `node scripts/ratchets.mjs tighten` — потолки опускаются
|
||||
(и поднимаются на принятый линией рост) до факта одним коммитом вместе с
|
||||
кандидатом; `npm run release:prerelease` печатает рыхлые потолки
|
||||
предупреждением. Абсолютный бюджет стартового графа
|
||||
(`INITIAL_VIEW_GZIP_BUDGET`) остаётся стеной.
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
@@ -868,7 +1028,7 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
| `S2-analysis` | Аналитика и оценка |
|
||||
| `S3-spec` | ТЗ в работе |
|
||||
| `S4-spec-review` | ТЗ на ревью |
|
||||
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код |
|
||||
| `S5-ready` | Готово к разработке — единственный статус, из которого можно начать трогать код в ветку задачи; локальный черновик `track:ask` — §11.8 |
|
||||
| `S6-in-progress` | В разработке, занято исполнителем |
|
||||
| `S7-code-review` | Код-ревью |
|
||||
| `S8-merged` | Ревью пройдено, код в `dev`, ждёт беты. Issue закрывается пачкой при выпуске |
|
||||
@@ -876,7 +1036,7 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: трек `track:ship`/`track:show`/`track:ask` (§5), тяжёлые проверки
|
||||
`ci:full`/`ci:golden`/`ci:mutants` (§5.1), `hotfix`, `process`, `review-4`;
|
||||
`ci:full`/`ci:golden` (§5.1), `hotfix`, `process`, `review-4`;
|
||||
приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`. Прежние `small` и
|
||||
`trivial` читаются как `track:show` (§5.1).
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
@@ -929,7 +1089,8 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
|
||||
|
||||
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
|
||||
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
|
||||
`Issue: #NN`, требует ровно один `User-Visible: yes|no` (коммит только из
|
||||
файлов класса C — без трейлеров, §3 п.10, #701), а для коммитов,
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
|
||||
`Baseline-Reviewed: <URL GitHub run>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
|
||||
@@ -982,7 +1143,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`;
|
||||
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
|
||||
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
|
||||
7. документов ревью одного вида (`SPEC-REVIEW`, `CODE-REVIEW`) на один issue
|
||||
не больше шести (`-r1`…`-r6`, `REVIEW_DOC_LIMIT`): четыре цикла плюс два
|
||||
ребейза (§4).
|
||||
|
||||
С токеном GitHub:
|
||||
|
||||
@@ -1003,6 +1166,13 @@ Validate стартует от этого push и успевает прочит
|
||||
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
|
||||
`S7-code-review`, то есть заведомо вне рабочего множества.
|
||||
|
||||
**Инфраструктурный диапазон статуса не требует** (#562, §1): если в диапазоне нет
|
||||
ни одного файла класса A, задача ещё не вошла в поток — она войдёт в него сразу на
|
||||
`S7-code-review`, — и отсутствие S-метки не отказ. Признак механический, по
|
||||
диффу, а не по метке `infra`. Кроме задачи в `S3-spec` или `S4-spec-review`:
|
||||
она уже в маршруте ТЗ, её ветка до `S5` не пушится (§11.8), и диапазон без
|
||||
класса A судится, как любой другой (#753).
|
||||
|
||||
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
|
||||
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
|
||||
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
|
||||
@@ -1010,6 +1180,45 @@ Pre-push передаёт целевую remote ref через `--target-ref`,
|
||||
`TARGET_REF`; оба исключают только уже опубликованную prerelease-историю. Любой
|
||||
post-beta коммит остаётся в проверке и по закрытому issue отклоняется fail-closed.
|
||||
|
||||
**Граница проверенного материала на `dev` и `main` одна** (#703). Гейты диапазона
|
||||
Validate — провенанс, процессный гейт, `no-new-any`, `no-new-private-writes` —
|
||||
судят коммиты после самого нового из:
|
||||
|
||||
- коммита, который судил завершённый (не отменённый) Validate на `dev` **или**
|
||||
`main` — прогоны обеих интеграционных веток читаются вместе;
|
||||
- коммита с опубликованным тегом `vX.Y.Z[-beta.N|-rc.N]`: тег ставится только на
|
||||
SHA с зелёным Validate (§8) и не зависит от окна в сто прогонов API;
|
||||
- самого HEAD, если у него уже есть **успешный** Validate (или тег).
|
||||
|
||||
Поэтому stable-промоушен — пуш в `main` SHA, проверенного на `dev`, — получает
|
||||
пустой диапазон и тот же вердикт, что дерево на `dev`. Бета-линия не судится
|
||||
заново по сегодняшним правилам: до #703 база бралась по прогонам одной `main`, то
|
||||
есть от прошлого stable, и run 36468413524 предъявил 55 записей в смоках, сделанных
|
||||
до #629. Упавший прогон HEAD границей не служит: перезапуск красного SHA судит тот
|
||||
же диапазон, а hotfix поверх кандидата — от кандидата. Реализация —
|
||||
`scripts/classify-base.mjs --mode=range`, тесты — `test/promotion-range.test.mjs`.
|
||||
|
||||
С тем же `--issues`, по timeline issue (#311, #738):
|
||||
|
||||
10. статус задачи **на момент написания** кода. Правило 8 видит только текущую
|
||||
метку, поэтому каждый коммит класса A, кроме релизного, сверяется по
|
||||
`authorDate` со шкалой `labeled`-событий статусных меток: эпоху «можно
|
||||
трогать код» открывает метка из множества проверки 8 (с `--no-merged` — без
|
||||
`S8-merged`), закрывают `S1-new`, `S2-analysis`, `S3-spec` и
|
||||
`S4-spec-review`, остальные метки (`blocked`, `track:*`, `review-4`) её не
|
||||
меняют; статус на момент коммита — последнее статусное событие не позже
|
||||
`authorDate`. Отказ — код, написанный до первого «Готово к разработке» или
|
||||
после возврата `S5+ → S3/S4` (`reclassify`, ручной возврат) и до повторной
|
||||
готовности: в эпоху `S4` код класса A не пишется, как и в `S3`. Коммиты,
|
||||
написанные до возврата, остаются законными. Без timeline или без единого
|
||||
события из множества проверки 8 — предупреждение, а не отказ: проверка
|
||||
вторична, fail closed по статусу держит правило 8. Исключение одно —
|
||||
черновик `track:ask` (§11.8, #729): коммит из эпохи `S4-spec-review`,
|
||||
которую закрыл `S5-ready`, принимается, если трек на момент написания —
|
||||
`ask`, а трейлер `Spec-Draft` равен «Тело issue» зелёного `SPEC-REVIEW`
|
||||
этой эпохи из вершины диапазона или `origin/dev`; трейлер на коммите вне
|
||||
`S4` — предупреждение.
|
||||
|
||||
Не реализовано и остаётся долгом:
|
||||
|
||||
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
|
||||
@@ -1048,6 +1257,7 @@ SHA. Manifest публикуется и входит в `SHA256SUMS`; свежа
|
||||
```
|
||||
S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec
|
||||
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
|
||||
(show, route: reclassify → S3-spec, #726)
|
||||
```
|
||||
|
||||
Текущая техническая реализация независимого ревьюера —
|
||||
@@ -1071,15 +1281,23 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
умолчанию», — общее для всех таких файлов;
|
||||
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
|
||||
промежутке — она утверждает, что код в `dev`;
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
4. многострочный текст в `run:` строкой не пишется: сообщение коммита —
|
||||
`echo` построчно в файл, текст комментария или сводки — из скрипта (файлом
|
||||
или `--summary`). Строка с нулевым отступом обрывает блок YAML, и скрипт
|
||||
обрезается без ошибки парсера; текст из кода к тому же проверяет тест, а
|
||||
текст из YAML — нет. Новые шаги heredoc не добавляют, старые переводятся при
|
||||
переработке шага (#723, #730).
|
||||
|
||||
**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623). Для
|
||||
событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из
|
||||
`main`. Таких файлов шесть: `process.yml`, `process-resume.yml`,
|
||||
**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623, #716).
|
||||
Для событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из
|
||||
`main`. `workflow_dispatch` исполняет файл с выбранной ветки, но кнопку и сам
|
||||
запуск (`gh workflow run`, API) даёт только workflow, чей файл лежит в `main`:
|
||||
файл, добавленный в `dev`, до промоушена не запускается вовсе (#716). Таких
|
||||
файлов восемь: по событиям — `process.yml`, `process-resume.yml`,
|
||||
`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`,
|
||||
`process-metrics.yml`. Каждый — тонкий вызывающий: триггеры, run-name,
|
||||
права, concurrency и одна job `uses:
|
||||
`process-metrics.yml`; по кнопке, но до промоушена — шаги беты
|
||||
`ship-review.yml` (§11.7) и `beta-derived.yml` (§8). Каждый — тонкий
|
||||
вызывающий: триггеры, run-name, права, concurrency и одна job `uses:
|
||||
Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`.
|
||||
Тело `_<имя>.yml` читается из `dev` в момент запуска, поэтому **правка
|
||||
конвейера — один коммит в `dev`**, зеркало в `main` и возврат `main` в `dev`
|
||||
@@ -1088,28 +1306,124 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
|
||||
получает прежний минимум (#556). Тонкий файл меняется, только когда меняются
|
||||
триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в
|
||||
`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными —
|
||||
сверяются ровно эти шесть файлов, список держит
|
||||
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
|
||||
по расписанию он судит `main` собственным телом из `main`.
|
||||
сверяются ровно эти восемь файлов, список держит
|
||||
`test/default-branch-workflows.test.mjs`. Новый тонкий файл сначала
|
||||
зеркалится в `main`, затем сливается в `dev`: в обратном порядке push в `dev`
|
||||
найдёт файл, которого нет в `main`. Расхождение красит push в `dev` и
|
||||
заводит одно issue владельцу (`[workflow-sync]`), а на ветке задачи —
|
||||
предупреждение в сводке (#700): к её изменению оно отношения не имеет, и чинит
|
||||
его тот, кто зеркалит в `main`. Так же судятся внешние ссылки документации
|
||||
(`check-docs --external=warn` на ветках `issue/*`): упавший чужой сайт не
|
||||
возвращает задачу. `performance.yml` в список не входит:
|
||||
по расписанию он судит `main` собственным телом из `main`. Прочие workflow по
|
||||
кнопке приезжают в `main` целиком с промоушеном; новый такой файл, который
|
||||
нужен до него, заводится тонким и входит в список. Ночь (`_nightly.yml`) после
|
||||
Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly` (§11.7,
|
||||
#727): входы и права для этого в `main` уже есть, тонкие файлы не меняются.
|
||||
|
||||
**Скрипты конвейера — из `dev`** (#749). Тело читается из `dev`, поэтому и
|
||||
скрипты, которые оно зовёт со своими флагами и форматами, — версии `dev`. Job
|
||||
`integrate` сразу после `setup-node` снимает один снимок `git archive
|
||||
origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый
|
||||
repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`,
|
||||
`merge-candidate`, `process-track route`, `status-label`. Снимок один на job —
|
||||
одна версия на весь заход, а не смесь по шагам. Job `prepare` (#765) делает
|
||||
то же до перехода на ветку задачи, но архивом **закреплённого** SHA
|
||||
(`git rev-parse origin/dev` один раз — `git fetch` трека и ребейза его не
|
||||
сдвигают): из снимка идут шаг трека (#707), страж ребейза (#698) с разбором
|
||||
отказа push (#723), хеш тела issue для якоря, reuse зелёного
|
||||
вердикта (#499), Validate-гейт (#510) и проверка изменившегося ТЗ (#517).
|
||||
Прежде четыре последних исполняли скрипты ветки — материал сам решал, допущен
|
||||
ли он к ревью: подменённый `review-doc-guard.mjs` выдавал себе `reuse=true`.
|
||||
SHA уходит выходом `tools_sha` в `model_review`. Рабочая копия ветки
|
||||
задачи — только материал: git-команды, документ и пути судятся в ней. Ветка
|
||||
`show`/`ship` с чистым слиянием до ревью не ребейзится и может нести
|
||||
`scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча
|
||||
проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому.
|
||||
`validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от
|
||||
себя, без него доказательство Validate кандидата — `failed (#622)`. В job
|
||||
`model_review` скрипты материала исполняет ревьюер — это его работа, а не
|
||||
конвейера; детерминированный шаг «Снять расход модели» (#737) — работа
|
||||
конвейера, и `model-usage.mjs` он берёт архивом `tools_sha` (#765): на
|
||||
отставшей ветке скрипта нет, и публикация молча писала `reason=missing`. От
|
||||
сессии модели на том же раннере снимок не защищает, поэтому строка расхода
|
||||
по-прежнему недоверенный ввод и разбирается строго. Следствие для задач, меняющих сам конвейер: их
|
||||
слияние судит версия `dev`, новая начинает действовать со следующей задачи.
|
||||
Правка контракта доказательства Validate (имена потребляемых job
|
||||
`validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с
|
||||
собственным слиянием — старое имя потребляемой job живёт до слияния, — иначе
|
||||
задачу сливает владелец.
|
||||
|
||||
**Красная ночь** (#736). Красный ночной Validate — сигнал с адресатом: job
|
||||
`night_red` в `_nightly.yml` (`scripts/night-red.mjs`) пишет по одному
|
||||
комментарию в каждую задачу, чьи коммиты классов A/B вошли в `dev` после
|
||||
последней зелёной ночи. Пишет только при `conclusion: failure`: отменённый или
|
||||
прерванный по времени прогон — строка сводки. Последняя зелёная ночь — новейший
|
||||
из 50 последних dispatch-прогонов Validate на `dev`, который создан раньше
|
||||
красного, завершён успехом, стоит на предке красного SHA и несёт доказательство
|
||||
`ci-proof` по политике `release` (лёгкий зелёный прогон — `stale`, ручной полный
|
||||
запуск считается ночью). Не нашлась — комментариев нет. Подозреваемая задача —
|
||||
каждый `Issue: #NN` коммита из `git rev-list --no-merges <зелёная>..<красная>`
|
||||
(коммиты ветки, влитой слиянием, входят) с файлом класса A или B и без трейлера
|
||||
`Release:`; коммит только из `docs/**` не считается, коммит без трейлера —
|
||||
строка «без задачи» в сводке, пустой диапазон — «вероятен флак». Комментарий
|
||||
называет прогоны, коммиты задачи и упавшие job, говорит «подозреваемая, а не
|
||||
виновная» и несёт метку `hp:night-red green=… red=… commits=…`. Повтор не
|
||||
пишется в закрытую задачу и туда, где метка с той же зелёной ночью уже
|
||||
перечисляет все нынешние коммиты задачи в диапазоне: серия красных ночей от
|
||||
одной зелёной — один комментарий, пока задача не докоммитит; зелёная ночь
|
||||
начинает новую серию. Комментарии пишет `HP_PROCESS_TOKEN`, Actions читает
|
||||
`github.token`: `issues: write` у ночи нет, потолок тонкого `nightly.yml` не
|
||||
меняется. Цвет ночи — цвет Validate: сбой `night_red` — предупреждение.
|
||||
|
||||
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
|
||||
снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и
|
||||
диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1:
|
||||
инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`.
|
||||
Шаг трека делает **один вызов** скрипта по меткам, диффу и комментариям issue
|
||||
(#707): трек и его основание (строка владельца, §5), рамки `ship`, риск по
|
||||
изменённым участкам, повышение `ship` → `show`, заметку риска в промпт ревью
|
||||
(не длиннее 25 строк) и строку риска для комментария слияния `ship`. Bash шага
|
||||
только исполняет: комментарий берёт из файла скрипта, метки меняет по его флагу.
|
||||
Guard берёт трек и лимит циклов из того же скрипта (`process-track.mjs
|
||||
limit`) и своей логики трека не держит; ответ compare API на 300 файлов и
|
||||
больше инфраструктуру не доказывает — `ask`, лимит 4. Этап `spec`, отсутствие
|
||||
ветки и инфраструктурный дифф дают пустой риск и прежнее поведение.
|
||||
|
||||
**Маршрут вердикта** (#726). Структурный вердикт модели — `verdict`, `high`,
|
||||
`medium`, `summary`, `route` (`fix` | `reclassify`, обязательное в схеме) и
|
||||
необязательный `criterion`. Граница доверия (`scripts/review-result-gate.mjs`)
|
||||
читает отсутствующий `route` как `fix`, отвергает значение вне словаря и
|
||||
`reclassify` при зелёном вердикте: результат противоречив, модель не
|
||||
перезапускается. Смысл `criterion` судит шаг «Решение по вердикту»: **один
|
||||
вызов** `process-track.mjs route` по вердикту, треку и его подтверждению
|
||||
владельцем (выход шага трека), бюджету guard и текущим меткам; статус, метки и
|
||||
тело комментария (файлом) — из выхода скрипта. Метки трека, `blocked` и
|
||||
`review-4` ставит `gh issue edit` по этому выходу, статус — `status-label.mjs`,
|
||||
как прежде. Шаги без модели (`ship` в рамках, повторно применённый зелёный)
|
||||
скрипт не зовут. Заметку маршрута в промпт даёт шаг трека: на код-ревью `show` —
|
||||
поля и список критериев §5, иначе `route: fix`. Документ ревью несёт маршрут
|
||||
хвостом строки якоря — «Вердикт конвейера: `yellow` · High 0 · маршрут
|
||||
`reclassify` (критерий `undocumented`)», — сводка прогона печатает маршрут и
|
||||
бюджет после вердикта.
|
||||
|
||||
| | `ship` | `show` | `ask` |
|
||||
|---|---|---|---|
|
||||
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | с мутантами по диффу, dispatch |
|
||||
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | лёгкий, как у `ship` (#709) |
|
||||
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
|
||||
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
|
||||
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | dispatch с мутантами |
|
||||
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | как у `ship` (#709) |
|
||||
|
||||
Метка `ci:mutants` возвращает мутанты по диффу на любом треке. Рамки `ship`
|
||||
Мутантов по диффу нет ни на одном треке (#709, §2.7). Рамки `ship`
|
||||
(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена
|
||||
`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue
|
||||
комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя
|
||||
не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно
|
||||
применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
|
||||
`track:ship` на `track:show` в этом же заходе; так же — рискованный участок без
|
||||
подтверждения владельца (§5, #707), одним комментарием с нарушениями рамок.
|
||||
Слияние `ship` оставляет в issue комментарий с машинным маркером `hp:ship-merge`.
|
||||
Это не вердикт ревью и так себя не называет; по маркеру пакетное ревью находит
|
||||
задачи диапазона. Если `ship` слит с риском (подтверждён владельцем или риск
|
||||
только `visual`), комментарий несёт строку «Риск по участкам …» и машинную
|
||||
`<!-- hp:ship-risk classes=… -->`; строка маркера `hp:ship-merge` не меняется.
|
||||
Повторно применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
|
||||
|
||||
В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью
|
||||
кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там.
|
||||
@@ -1119,14 +1433,14 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
|
||||
**Ревью `show` судит корректность и AC.** Medium — дефект поведения, который
|
||||
увидит пользователь, или невыполненный AC. Бухгалтерия — нет мутанта или записи
|
||||
в реестре, нечувствительный тест на побочный вызов, формулировка в документе —
|
||||
Low и цикла не открывает. Отсутствие мутантов по диффу на `show` не находка:
|
||||
полный реестр гоняется ночью. Ревью `show` не ставит Chromium, если тело issue
|
||||
Low и цикла не открывает. Отсутствие прогона мутантов не находка ни на каком
|
||||
треке: полный реестр гоняется ночью (#709). Ревью `show` не ставит Chromium, если тело issue
|
||||
не называет смоук или браузер. Ревью ТЗ не ставит ни `npm ci`, ни браузер: кода
|
||||
оно не исполняет.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
|
||||
берёт завершённый push-прогон Validate на этом SHA или запускает лёгкий
|
||||
(`scripts/validate-gate.mjs`: `workflow_dispatch validate.yml`). **Ждёт его не раннер, а событие**
|
||||
(#636): подготовка убеждается, что dispatch встал на материал, кладёт запечатанный
|
||||
маркер ожидания `review-pending-…` и завершается; по завершении Validate
|
||||
`process-resume.yml` (`workflow_run`) переставляет метку `S7-code-review`, и новый
|
||||
@@ -1135,18 +1449,23 @@ Low и цикла не открывает. Отсутствие мутантов
|
||||
завершённым Validate он будит повторной меткой, без маркера — как прежде, только
|
||||
диагностика. Будить без маркера нельзя: это второй вызов модели. Красный или
|
||||
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
|
||||
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
|
||||
только по явному запросу: на кандидате ревью, кандидате слияния (#492) — оба
|
||||
диспатчат Validate с `mutants=true` — и на PR, где Validate единственный сигнал;
|
||||
обычный push обходится дешёвыми гейтами (~3 минуты). За 08–09.09 мутанты на
|
||||
каждом промежуточном пуше стоили 48 из 56 часов job-минут Validate и в основном
|
||||
отменялись следующим пушем. **Кандидат беты (`Release:`), `full=true` и ночь
|
||||
мутантов не запрашивают** (#601, решение владельца 20.09): мутационный гейт
|
||||
проверяет тесты, а не продукт (#513), к бете каждая задача прогнана им дважды —
|
||||
на ревью и на слитом после ребейза кандидате, — а ночью идёт полный реестр
|
||||
(`mutation-gate.yml`, 00:43 UTC). Релизный гейт (#541) требует полного Validate,
|
||||
но не mutant-jobs; для ревью и слияния шесть исполненных mutant-jobs остаются
|
||||
обязательными.
|
||||
код никто не читал, цикл ревью не израсходован. **Мутанты по диффу в
|
||||
разработке не бегут вовсе** (#709): ни на кандидате ревью, ни на кандидате
|
||||
слияния, ни на PR, ни по кнопке. До #709 их запрашивали кандидаты ревью и
|
||||
слияния на `ask` (#510, #601), и заход стоил около 28 минут; к бете каждая
|
||||
задача прогонялась ими дважды. Мутанты проверяют тесты, а не продукт (#513), —
|
||||
весь реестр проверяет ночь (`mutation-gate.yml`, 00:43 UTC). Ни ревью, ни
|
||||
слияние, ни релизный гейт (#541) mutant-jobs не требуют.
|
||||
|
||||
**Поиск ожидающего раунда (#775).** Resume и автосверка читают историю до
|
||||
последней постановки review-метки, без окна «200 последних событий» и без
|
||||
лимита фильтрованного поиска Actions. Чужая разметка и полностью skipped-прогоны
|
||||
не заслоняют ожидание; новый запрос метки отделяет старый раунд. Resume сверяет
|
||||
issue, stage, run/attempt, ветку, её текущий SHA и точный ID Validate с запечатанным
|
||||
маркером. Перед перестановкой метки состояние перечитывается. При ещё невидимом
|
||||
прогоне/артефакте — не более трёх снимков с паузами по 5 секунд, далее страховка
|
||||
автосверкой. Поиск не перескакивает через завершённую попытку ревью без маркера:
|
||||
так нельзя доказать, что повторный вызов модели безопасен.
|
||||
|
||||
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
|
||||
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
|
||||
@@ -1160,6 +1479,25 @@ merge. Timeout/cancel/failure называет конкретную стадию
|
||||
если модель не запускалась, цикл ревью не расходуется. Длительности всех трёх
|
||||
стадий печатаются отдельной таблицей в summary прогона.
|
||||
|
||||
**Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения
|
||||
сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели»
|
||||
сразу после `Review` (`if: always()`, `continue-on-error`) — шаг конвейера, а не
|
||||
ревьюера, со скриптом из снимка `dev` (#765) — берёт из файла только
|
||||
последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку
|
||||
выходом job `usage`: `<!-- hp:usage input_tokens=N output_tokens=N
|
||||
cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` — суммы
|
||||
по всем моделям `modelUsage`, без него — из `usage`. Нет данных —
|
||||
`<!-- hp:usage-none reason=<код> -->`: `no-execution-file`, `unreadable`,
|
||||
`no-result`, `no-usage`, у публикации ещё `missing` и `invalid`. Расход —
|
||||
отчётная величина, как длительность стадии: по нему ничего не решается, поэтому
|
||||
он идёт выходом job, а не запечатанным artifact, и `REQUIRED_FILES` остаётся
|
||||
«ровно эти файлы». Сам файл не выгружается и не печатается: в нём результаты
|
||||
инструментов. Публикация разбирает строку как недоверенный ввод и пишет её
|
||||
последней строкой блока якорей (`review-doc-guard.mjs --anchor --usage=`), а в
|
||||
`SHIP-REVIEW` — сразу после машинного блока. «Метрики процесса» читают строку
|
||||
только из машинного блока: цитата в прозе не считается, «нет данных» — не ноль.
|
||||
Расход упавшей сессии виден только в сводке прогона.
|
||||
|
||||
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
|
||||
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
|
||||
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
|
||||
@@ -1230,15 +1568,29 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и
|
||||
лёгкий Validate кандидата уже запустил сам push в ветку: слияние ждёт этот
|
||||
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
|
||||
к другому диффу не применим, §2.10; метка снимается и ставится заново, чтобы
|
||||
событие запустило новый заход, #706), публикация кандидата в ветку задачи и
|
||||
ожидание зелёного лёгкого Validate **на этом SHA** — его запускает сам push в
|
||||
ветку, dispatch — только если прогона нет за три минуты (#696, #709), — и только
|
||||
затем push в `dev` с lease на ту вершину, поверх которой кандидат собран.
|
||||
Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- push, отклонённый самим GitHub (`! [remote rejected]`), — не lease и не
|
||||
#312: `S6-in-progress` с причиной и ответом git без токена (#705). Отказ по
|
||||
праву на workflow (кандидат меняет `.github/workflows/`, токен конвейера его
|
||||
не публикует) называется отдельно: ребейз и push делает автор, либо
|
||||
владелец выдаёт право. Страж ребейза до ревью разбирает отказ тем же кодом, как
|
||||
и публикация документов ревью — в ветку задачи и ревью релиза в `dev` (#723),
|
||||
пакетного ревью ship в `dev` и бот-коммит производных артефактов беты (#730):
|
||||
повтор, ребейз или совет перезапустить — только на устаревший lease, отказ
|
||||
GitHub останавливает шаг с причиной и ответом git без токена в сводке (у
|
||||
стража ребейза тоже, #730);
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
|
||||
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
|
||||
прилетевший после слияния, её сохраняет, и комментарий об этом говорит.
|
||||
Влитая ветка никому не нужна, а агент, ищущий ветку по номеру, иначе может
|
||||
взять устаревшую.
|
||||
|
||||
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
|
||||
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
|
||||
@@ -1401,6 +1753,10 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
**Выпуск не блокирует.** `release.yml` ставит ревью в очередь job
|
||||
`independent-review` сразу после закрепления SHA кандидата — параллельно
|
||||
гейтам; ни один job выпуска от него не зависит, его отказ — предупреждение.
|
||||
Dispatch идёт токеном `GITHUB_TOKEN`, прогон начинает `github-actions[bot]`, и
|
||||
action ревью пускает ровно этого бота (`allowed_bots`, не `'*'`). Job находит
|
||||
поставленный прогон и пишет в сводку выпуска ссылку и статус; прогон, не
|
||||
стартовавший за три минуты, — предупреждение (#704).
|
||||
Документ — рекомендация: владелец берёт находки в работу (issue в очередь
|
||||
следующей беты) либо оставляет без действий. Автоматически находки в issue
|
||||
не превращаются.
|
||||
@@ -1438,7 +1794,10 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
|
||||
|
||||
**Шаг.** Перед публикацией беты — `ship-review.yml`
|
||||
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`):
|
||||
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N` или кнопка
|
||||
«Run workflow» в Actions). Это тонкий вызывающий файл, он лежит и в `main`:
|
||||
без этого GitHub запуск не даёт. Тело `_ship-review.yml` читается из `dev` при
|
||||
любой выбранной ветке (§10.4, #716):
|
||||
|
||||
- **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат»
|
||||
(тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с
|
||||
@@ -1446,23 +1805,122 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
`scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается;
|
||||
- **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код
|
||||
заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по
|
||||
смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
|
||||
смыслу. Под задачей бриф печатает строку риска из её комментария
|
||||
`hp:ship-merge` (`hp:ship-risk`, #707): рискованные участки ship,
|
||||
подтверждённого владельцем, конвейер не повышал — их первым читает пакетное
|
||||
ревью. Комментарии до #707 строки не несут — риск не записан. Правила
|
||||
ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
|
||||
- **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком:
|
||||
задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует
|
||||
задачи, счёт High/Medium/Low, режим (`mode`) и патч-набор каждой задачи
|
||||
(`patches`, #727). У модели нет прав записи, документ публикует
|
||||
детерминированный шаг.
|
||||
|
||||
**Ночь** (#727). После ночного полного Validate, при любом его исходе,
|
||||
`_nightly.yml` запускает `ship-review.yml -f tag=nightly -f candidate=<SHA
|
||||
прогона Validate>`: ревью читает код, а не прогон. `nightly` — зарезервированное
|
||||
значение входа `tag`, кандидат в нём обязателен. Ночь читает только непокрытые
|
||||
ship-задачи диапазона «прошлый тег..кандидат» и пишет
|
||||
`docs/reviews/SHIP-REVIEW-<база>-dev-<sha12>.md`. Читать нечего — модели и
|
||||
документа нет; документ на этот SHA уже есть — прогон ничего не делает
|
||||
(`force=true` — переснять). High ночью — одна строка в каждую задачу документа
|
||||
(метка `hp:ship-review-high`, повтор на тот же документ не пишется); Medium и
|
||||
Low в issue не пишутся. Цвет ночи — цвет Validate.
|
||||
|
||||
**Патч-набор и покрытие** (#727). Патч-набор задачи — `git patch-id --stable`
|
||||
её коммитов диапазона, кроме коммитов с трейлером `Release:` (кандидат беты
|
||||
несёт `Issue:` всех задач линии) и коммитов только в `docs/reviews/**`; его
|
||||
пишет детерминированный шаг, не модель. Покрытие задачи решает последний
|
||||
опубликованный документ той же базы, где она есть: тот же патч-набор и High 0 —
|
||||
`clean`, High больше нуля — `high` (High снимает покрытие со всех задач
|
||||
документа), другой патч-набор — `stale`: код изменился после ревью. Задачи нет
|
||||
ни в одном документе — `none`. Документ без строки `patches` (до #727)
|
||||
покрывает по номеру; документы другой базы не учитываются.
|
||||
|
||||
**Бета читает дельту** (#727). `ship-review.yml -f tag=<тег>` читает только
|
||||
задачи `none` и `stale`, бриф называет прочитанные ночью. Задачи `high` без
|
||||
`force=true` не перечитываются: их держит гейт. Дельта пуста — модель не
|
||||
запускается, документа тега нет. Документ тега перечисляет только прочитанные
|
||||
в нём задачи. `force=true` читает все ship-задачи диапазона и на ночные
|
||||
документы не смотрит.
|
||||
|
||||
**Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации —
|
||||
`publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`).
|
||||
Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в
|
||||
`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует
|
||||
пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью
|
||||
переснимается. Medium и Low решает владелец, как в §11.5.
|
||||
Каждая ship-задача диапазона обязана быть `clean`: гейт беты принимает ночной
|
||||
документ при том же патч-наборе, документ тега для этого не нужен. `none` и
|
||||
`stale` — отказ с номерами и командой `ship-review.yml -f tag=<тег>`. High
|
||||
чинится отдельной задачей, затем ревью переснимается (`-f force=true`). Medium
|
||||
и Low решает владелец, как в §11.5.
|
||||
|
||||
### 11.8 Черновик кода во время ревью ТЗ
|
||||
|
||||
Решение владельца 2026-10-01, issue #729 (выделено из #707).
|
||||
|
||||
**Зачем.** На `track:ask` каждый раунд ревью ТЗ — 10–45 минут ожидания
|
||||
(§10.4), и по правилу №1 автор всё это время код не пишет. Черновик позволяет
|
||||
вести реализацию параллельно ревью. **Цена:** окно, в котором код пишется по
|
||||
ещё не принятому тексту; после жёлтого или красного вердикта такой код
|
||||
переделывается, и токены на него потрачены зря. Экономия ожидания против этого
|
||||
окна — выбор владельца; поэтому **черновик — возможность, а не обязанность**.
|
||||
|
||||
**Условия.**
|
||||
|
||||
- Только `track:ask`, только после того, как ТЗ в теле issue отправлено и
|
||||
стоит `S4-spec-review`. Под `blocked` и `review-4` черновик не ведётся.
|
||||
- Один checkout и одна каноническая ветка: черновик живёт в рабочей копии
|
||||
автора задачи, на локальной ветке `issue/<NN>-<slug>` — той, что будет
|
||||
запушена после `S5`. Вторых веток `issue/<NN>-*` и параллельных черновиков
|
||||
нет.
|
||||
- До `S5` ничего не пушится — ни коммиты класса A, ни сама ветка: на этапе
|
||||
spec конвейер выбирает ветку `issue/<NN>-*` материалом и кладёт в неё
|
||||
SPEC-REVIEW. Если ветка уже есть на origin (после `reclassify` #726 или
|
||||
запушена в `S3`), черновик после `S5` ребейзится на `origin/issue/<NN>-*`,
|
||||
где лежит SPEC-REVIEW, а не только на `dev`; force-push поверх неё запрещён.
|
||||
- Статус остаётся `S4-spec-review`; занятие `S6-in-progress` — только после
|
||||
зелёного ревью ТЗ.
|
||||
- Начиная черновик, автор пишет комментарий «Черновик:» по шаблону §7.2. С
|
||||
этого момента черновик занимает слот WIP автора (§2.6).
|
||||
- **Трейлер.** Каждый черновой коммит несёт ровно один трейлер
|
||||
`Spec-Draft: sha256:<64 строчных hex>` в финальном блоке рядом с `Issue:` и
|
||||
`User-Visible:`. Значение — `issueBodyDigest(<тело issue>)` из
|
||||
`scripts/review-doc-guard.mjs`: та же функция, которой конвейер пишет «Тело
|
||||
issue:» в якорь документа ревью. Строку печатает пакет задачи в `S4`
|
||||
(`node scripts/task-packet.mjs --issue NN`, раздел «Черновик»). Хеш
|
||||
считается по телу на момент начала черновика; тело правилось после этого —
|
||||
хеш другой, это и есть «ТЗ правилось».
|
||||
|
||||
**Выход.** Зелёный вердикт ставит `S5-ready`. Автор берёт `S6-in-progress`,
|
||||
делает `git fetch`, ребейзит черновик на `origin/dev` (или на
|
||||
`origin/issue/<NN>-*`, если ветка была), проверяет
|
||||
`node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report` и
|
||||
пушит. **В ветку черновик попадает только после зелёного ревью ТЗ.** Push,
|
||||
`S7` и слияние, как и прежде, идут только после зелёного ревью актуальной
|
||||
версии ТЗ: это держат правило 8 (§10.2) и шаг «ТЗ менялось после зелёного
|
||||
ревью ТЗ» (#517).
|
||||
|
||||
**Жёлтый или красный вердикт** возвращает задачу в `S3-spec`, и черновик
|
||||
останавливается. Его коммиты в ветку не попадают: как есть их отклонит гейт, а
|
||||
перенос с переписанной датой автора (`--reset-author`, `--date`, новый коммит
|
||||
из старого диффа) запрещён §12. В следующей эпохе `S4` можно начать новый
|
||||
черновик — по новому тексту и с новым хешем.
|
||||
|
||||
**Проверка** — правило 10 `process-gate.mjs` (§10.2): коммит класса A,
|
||||
написанный в эпохе `S4-spec-review`, принимается, только если эту эпоху закрыл
|
||||
`S5-ready`, трек на момент написания — `ask`, и трейлер равен «Тело issue»
|
||||
зелёного (High 0) `SPEC-REVIEW` этой эпохи из вершины диапазона или
|
||||
`origin/dev`. Отказ называет причину; при устаревшем ТЗ — оба хеша и документ.
|
||||
Комментарий «Черновик:» и учёт в WIP машиной не проверяются, как занятие `S6`.
|
||||
|
||||
Ревьюер ТЗ черновик не читает и доводом не считает: судится тело issue
|
||||
(§2.4).
|
||||
|
||||
---
|
||||
|
||||
## 12. Запрещено
|
||||
|
||||
- код без issue или из статуса раньше «Готово к разработке»;
|
||||
- код без issue или из статуса раньше «Готово к разработке» (кроме черновика
|
||||
`track:ask` по §11.8);
|
||||
- push черновика до `S5`; перенос черновика по устаревшему ТЗ коммитами с
|
||||
переписанной датой автора;
|
||||
- ТЗ, написанное после кода (кроме §11.2, и тогда с пометкой «как сделано»);
|
||||
- ревью своей работы; перевод своей работы через ревью-гейт;
|
||||
- пятый цикл ревью вместо разбора по §4;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JB (justbusiness)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,47 @@
|
||||
# House Plan moon pack 1.0.0
|
||||
|
||||
Designer asset for [issue #661](https://github.com/Matysh/houseplan-card/issues/661)
|
||||
(moon in its current phase on the "Follow the Sun" background), variant A:
|
||||
one full-moon image, the phase is applied by the card as an SVG mask (C5).
|
||||
|
||||
- `moon.svg`: square `viewBox="0 0 512 512"`, disc centred at (256, 256),
|
||||
diameter 480 (16 px margin for anti-aliasing). Full moon, front-lit: no
|
||||
terminator, no shadow, no halo or glow outside the disc, no baked phase.
|
||||
Near side, north up, Mare Crisium on the right; the art is never mirrored.
|
||||
Owner 2026-09-29: the lit side is always the left one (as in the designer's
|
||||
phase sequence), waning runs the same states backwards; only the mask flips.
|
||||
Owner 2026-09-30: a distinct state for every day of the lunar month; the mask
|
||||
is continuous, so the card simply uses the daily illumination k.
|
||||
The dark side is not drawn: the card renders the same art at 8 % opacity.
|
||||
- Edge: one radial gradient ring with no hard outer edge (as in the designer's
|
||||
Figma export): bright rim 0.855-0.90 R, dark outline `#2b3138` at 50 % from
|
||||
0.905 R to 0.92 R (owner 2026-09-29, for a forced white background), then a
|
||||
1.5-unit fade to transparent by 0.935 R (r 239.4 in the 512 box, inside the 480
|
||||
disc). The soft limb hides any missing anti-aliasing of the outer edge.
|
||||
- The disc is masked (`<mask>`), not clipped: Chrome with GPU rasterisation draws
|
||||
`clip-path` edges without anti-aliasing (seen on Windows), masks are anti-aliased.
|
||||
- Art by JB (Figma, `House-plan` file). The Figma export (`source/figma-export/Full.svg`
|
||||
in the source archive) was normalised for the pack by `source/convert_jb.py`:
|
||||
viewBox 200 -> 512 (scale 2.56, disc clipped at r 240), the `mix-blend-mode`
|
||||
screen ring and multiply groups removed (blend modes depend on the stacking
|
||||
context they land in, so the file now composites normally and looks the same
|
||||
everywhere), the three 5-7 % crater layers and the 484-path speckle texture
|
||||
dropped (invisible at 200 px), and each mare "blend" of 22 nested rings
|
||||
thinned to 4 rings (steps 0, 8, 16, 21). The export's screen-blended edge ring is rebuilt
|
||||
as a normal-composited radial gradient (the art is masked at r 230), so the feathered edge is part
|
||||
of the 480 disc and nothing is drawn outside it. Path coordinates rounded to 0.1, gradient offsets kept at 0.001 (`svgo.config.mjs`).
|
||||
- Vector only: two `radialGradient`s (disc, edge), one `mask`, paths. No `filter`,
|
||||
`feGaussianBlur`, `feImage`, embedded raster, fonts or external references.
|
||||
25.6 KB raw, about 9 KB gzip.
|
||||
- Colours: disc `#949493` (centre) -> `#D6D5D4` (rim), maria grey rings
|
||||
`#404040 -> white` under a 0.2-0.6 group opacity. Checked on the night
|
||||
(`#111a27 -> #1f2f3e`), dusk (`#48536c -> #9a7380`) and dawn
|
||||
(`#aabdd1 -> #e8c8b7`) palettes at 200, 120 and 94 px.
|
||||
- `pack.json`: pack id, version, licence, source link and the disc geometry
|
||||
for `scripts/generate-moon-assets.mjs`.
|
||||
|
||||
JB (justbusiness) supplies this asset for House Plan to use, modify and
|
||||
distribute under the repository MIT License without separate UI attribution
|
||||
(`LICENSE.md`). The reviewed source archive with its SHA-256 is attached to
|
||||
issue #661. Generated TypeScript (`src/moon-art.generated.ts`) must not be
|
||||
edited by hand; regenerate it from this pack.
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"pack_id": "houseplan",
|
||||
"pack_version": "1.0.0",
|
||||
"title_ru": "Луна: полный диск для маски фазы",
|
||||
"title_en": "Moon: full disc for the phase mask",
|
||||
"author": "JB (justbusiness)",
|
||||
"license": "MIT",
|
||||
"source_url": "https://www.figma.com/design/cpGN8MhJGydwVOUzZv8dia/House-plan",
|
||||
"issue": 661,
|
||||
"file": "moon.svg",
|
||||
"view_box": [
|
||||
0,
|
||||
0,
|
||||
512,
|
||||
512
|
||||
],
|
||||
"disc": {
|
||||
"cx": 256,
|
||||
"cy": 256,
|
||||
"r": 240
|
||||
},
|
||||
"lit": "front",
|
||||
"north_up": true,
|
||||
"east_right": true,
|
||||
"notes_en": "Full moon, front-lit, no terminator, no shadow, no glow outside the disc; thin dark outline for light backgrounds. Art by JB (Figma export normalised: viewBox 512, blend modes removed, rings thinned). Phase mask and the 8 % dark side are applied by the card (C5). The art is never mirrored; the lit side is always the left one, waning runs the same states backwards (owner 2026-09-29).",
|
||||
"notes_ru": "Полная луна, освещение в лоб, без терминатора, тени и ореола; тонкая тёмная обводка для светлого фона. Арт JB (экспорт из Figma нормализован: viewBox 512, без blend-режимов, кольца морей прорежены). Маску фазы и тёмную сторону 8 % накладывает карточка (C5). Арт не зеркалится; освещённая сторона всегда слева, убывание идёт теми же состояниями в обратном порядке (владелец 29.09).",
|
||||
"mirror": false,
|
||||
"outline": {
|
||||
"color": "#2b3138",
|
||||
"opacity": 0.5,
|
||||
"from_r": 0.905,
|
||||
"to_r": 0.92,
|
||||
"fade_to_r": 0.935,
|
||||
"note": "part of the edge gradient, no separate stroke"
|
||||
},
|
||||
"lit_side": "left"
|
||||
}
|
||||
@@ -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.78.0"
|
||||
VERSION = "1.79.0-beta.6"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
@@ -91,7 +91,9 @@ MAX_IMPORT_PREVIEWS_TOTAL = 3
|
||||
DEFAULT_CONFIG: dict[str, object] = {
|
||||
"spaces": [],
|
||||
"markers": [],
|
||||
"settings": {"bg_mode": "daynight", "sun_ray_origin": "inner"},
|
||||
# #661: new installations get the moon; an existing config without the key
|
||||
# keeps its look (docs/SUN.md: an update never changes a stored plan).
|
||||
"settings": {"bg_mode": "daynight", "sun_ray_origin": "inner", "moon": True},
|
||||
}
|
||||
|
||||
# #42: THE stable public error-code contract. Every code a user-facing
|
||||
|
||||
@@ -86,6 +86,16 @@ def _lattice_points(value: Any) -> None:
|
||||
_lattice_point(point)
|
||||
|
||||
|
||||
def _scalar_points(value: Any) -> None:
|
||||
if not isinstance(value, list):
|
||||
return
|
||||
for point in value:
|
||||
if not isinstance(point, list):
|
||||
continue
|
||||
for index in range(min(2, len(point))):
|
||||
point[index] = canonicalize_number(point[index])
|
||||
|
||||
|
||||
def canonicalize_position(position: Any) -> Any:
|
||||
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
|
||||
result = copy.deepcopy(position)
|
||||
@@ -182,6 +192,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
_lattice_point(span.get("a"))
|
||||
_lattice_point(span.get("b"))
|
||||
|
||||
for strip in _records(space.get("led_strips")):
|
||||
# #780: LED strips snap to physical wall faces, like stairs and
|
||||
# furniture: remove JSON noise only, never pull a face contact
|
||||
# onto a lattice node.
|
||||
_scalar_points(strip.get("points"))
|
||||
|
||||
for marker in _records(root.get("markers")):
|
||||
_scalar_fields(marker, ("angle",))
|
||||
|
||||
|
||||
@@ -1,436 +1,598 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
|
||||
"fingerprint": "edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 300323,
|
||||
"initialViewGzipBytes": 300064,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 301845,
|
||||
"initialPanelGzipBytes": 301586,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2325,
|
||||
"initialPanelOnlyGzipBytes": 2322,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/editor-CbeYCXUv.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
|
||||
"houseplan-assets/backdrop-pick-BETofwqS.js",
|
||||
"houseplan-assets/de-CGN16z4H.js",
|
||||
"houseplan-assets/editor-CKSGjA_K.js",
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/fr-sigzpXyQ.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B0UGO6dl.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-egRvu3Zt.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CKoAX7VK.js",
|
||||
"houseplan-assets/iso-scene-render-BGDGIIc3.js",
|
||||
"houseplan-assets/led-strip-editor-Bv9Lr7lm.js",
|
||||
"houseplan-assets/led-strip-geometry-Do0rAWqY.js",
|
||||
"houseplan-assets/led-strip-runtime-BPf9yRa6.js",
|
||||
"houseplan-assets/live-interaction-runtime-BEBJqW9n.js",
|
||||
"houseplan-assets/moon-runtime-XZPiPFx9.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js",
|
||||
"houseplan-assets/pdf-export-BEz8A-WF.js",
|
||||
"houseplan-assets/space-editor-CgOBkVzb.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-9plmRfcK.js",
|
||||
"houseplan-assets/zigbee-topology-HuGqPEnw.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CikXQX3d.js"
|
||||
],
|
||||
"lazyGzipBytes": 456389,
|
||||
"lazyGzipBytes": 478388,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/editor-CbeYCXUv.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
"houseplan-assets/backdrop-pick-BETofwqS.js",
|
||||
"houseplan-assets/editor-CKSGjA_K.js",
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B0UGO6dl.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js",
|
||||
"houseplan-assets/zigbee-topology-HuGqPEnw.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 238192,
|
||||
"lazyEditorGzipBytes": 240846,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
"houseplan-assets/backdrop-pick-BETofwqS.js",
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-egRvu3Zt.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 27950,
|
||||
"lazyOnboardingGzipBytes": 28395,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js"
|
||||
"houseplan-assets/de-CGN16z4H.js",
|
||||
"houseplan-assets/fr-sigzpXyQ.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 56330,
|
||||
"lazyLocaleGzipBytes": 53847,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js"
|
||||
"houseplan-assets/iso-scene-render-BGDGIIc3.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 17885,
|
||||
"lazyIsometricGzipBytes": 12344,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 16940,
|
||||
"lazyFurnitureArtGzipBytes": 16943,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"houseplan-assets/pdf-export-BEz8A-WF.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 126938,
|
||||
"lazyNamespaceLocaleFiles": [
|
||||
"houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"houseplan-assets/settings-ru-CkNFmChi.js",
|
||||
"houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"houseplan-assets/topology-ru-BRKShmrQ.js"
|
||||
"lazyMoonFiles": [
|
||||
"houseplan-assets/moon-runtime-XZPiPFx9.js"
|
||||
],
|
||||
"lazyNamespaceLocaleGzipBytes": 21057,
|
||||
"lazyMoonGzipBytes": 11716,
|
||||
"lazyLedFiles": [
|
||||
"houseplan-assets/led-strip-field-CR9q2TG7.js",
|
||||
"houseplan-assets/led-strip-geometry-Do0rAWqY.js",
|
||||
"houseplan-assets/led-strip-runtime-BPf9yRa6.js"
|
||||
],
|
||||
"lazyLedGzipBytes": 10119,
|
||||
"lazyLedEditorFiles": [
|
||||
"houseplan-assets/led-strip-editor-Bv9Lr7lm.js",
|
||||
"houseplan-assets/led-strip-geometry-Do0rAWqY.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js"
|
||||
],
|
||||
"lazyLedEditorGzipBytes": 10495,
|
||||
"lazyNamespaceLocaleFiles": [
|
||||
"houseplan-assets/led-de-CspHDqq9.js",
|
||||
"houseplan-assets/led-fr-Dj352qSL.js",
|
||||
"houseplan-assets/led-ru-DLDnT8dh.js",
|
||||
"houseplan-assets/settings-de-bncD5GI2.js",
|
||||
"houseplan-assets/settings-fr-DZU5lA4h.js",
|
||||
"houseplan-assets/settings-ru-DZ0CUv3G.js",
|
||||
"houseplan-assets/support-de-Det6AAMZ.js",
|
||||
"houseplan-assets/support-fr-Be4oWisL.js",
|
||||
"houseplan-assets/support-ru-CxDd1gON.js",
|
||||
"houseplan-assets/tools-de-CZBZcSYH.js",
|
||||
"houseplan-assets/tools-fr-CBGY_ULt.js",
|
||||
"houseplan-assets/tools-ru-CRsRYMok.js",
|
||||
"houseplan-assets/topology-de-BVr1ANny.js",
|
||||
"houseplan-assets/topology-fr-Di5BqM3k.js",
|
||||
"houseplan-assets/topology-ru-BLuMP8mY.js"
|
||||
],
|
||||
"lazyNamespaceLocaleGzipBytes": 30010,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"sha256": "5f6dcedf274594075d8acd31295d8c23f6d1408c890c086825db6bcbaa6c9b21",
|
||||
"rawBytes": 54017,
|
||||
"gzipBytes": 17576,
|
||||
"path": "houseplan-assets/backdrop-pick-BETofwqS.js",
|
||||
"sha256": "24bb58cbe005ba11397da9abf6dab0e3da1a0823826d3004226d12173303c634",
|
||||
"rawBytes": 55329,
|
||||
"gzipBytes": 18024,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"houseplan-assets/settings-ru-CkNFmChi.js"
|
||||
"houseplan-assets/settings-de-bncD5GI2.js",
|
||||
"houseplan-assets/settings-fr-DZU5lA4h.js",
|
||||
"houseplan-assets/settings-ru-DZ0CUv3G.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-E17v3gXt.js",
|
||||
"sha256": "ed10d6c5e7a82b275c9422be67c2213219a75dba9aab0ddb3e98813249cbd1d0",
|
||||
"rawBytes": 94196,
|
||||
"gzipBytes": 28393,
|
||||
"path": "houseplan-assets/de-CGN16z4H.js",
|
||||
"sha256": "9d94ab98d69ac69a798d93b1cdf7ecb78152e9aa27ecfba33e6c73fe1cf39a20",
|
||||
"rawBytes": 89873,
|
||||
"gzipBytes": 27157,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-CbeYCXUv.js",
|
||||
"sha256": "75887638c4d76040651a3cf52c56de10dc19e0cbc7583ee169d9b20cc5987788",
|
||||
"path": "houseplan-assets/editor-CKSGjA_K.js",
|
||||
"sha256": "a1d1d5fe3320a1e21e3fa24e9400a35d9d09cdeab69937fa99a3f1f5a2df6f41",
|
||||
"rawBytes": 3833,
|
||||
"gzipBytes": 1584,
|
||||
"gzipBytes": 1581,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"sha256": "cdab9e5e612be9b98c49193a2a6287080c13f62aaefa9a28b69bac94dd934012",
|
||||
"rawBytes": 25600,
|
||||
"gzipBytes": 5056,
|
||||
"path": "houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"sha256": "dce01094a2b7016160cab5760498ba4b346bdb37339302e7513889c1cbe134f6",
|
||||
"rawBytes": 25605,
|
||||
"gzipBytes": 5057,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"sha256": "ee9fe5934eea69e549ff48d2e3fdabd7c20e0d9906a6f4623314f6eea48bf269",
|
||||
"rawBytes": 96627,
|
||||
"gzipBytes": 27937,
|
||||
"path": "houseplan-assets/fr-sigzpXyQ.js",
|
||||
"sha256": "052757291938dbdc4ee16693b35979a4192e5fa242e89672e491359745f4a032",
|
||||
"rawBytes": 92266,
|
||||
"gzipBytes": 26690,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"sha256": "f668d3c0a47a53e096f26728187b1666fbb6b421c1baa2ff1a649fa1ba5340d5",
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"sha256": "4b107848f545c559a87fb43e84fdc7f554433bcd728a804dd0d9b4acd30757e9",
|
||||
"rawBytes": 52035,
|
||||
"gzipBytes": 16940,
|
||||
"gzipBytes": 16943,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"sha256": "055b6f8ba3a472be163a15ef6e2348727b5b03e3660b9362f3aed21d7be90426",
|
||||
"rawBytes": 1059790,
|
||||
"gzipBytes": 299520,
|
||||
"path": "houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"sha256": "f479caf0ae99c89400f78f5dfc9ef39f3fcb16e6c90767b42d0a056d9df6cc8a",
|
||||
"rawBytes": 1056226,
|
||||
"gzipBytes": 299264,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/editor-CbeYCXUv.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
|
||||
"houseplan-assets/de-CGN16z4H.js",
|
||||
"houseplan-assets/editor-CKSGjA_K.js",
|
||||
"houseplan-assets/fr-sigzpXyQ.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-B0UGO6dl.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-egRvu3Zt.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-CKoAX7VK.js",
|
||||
"houseplan-assets/iso-scene-render-BGDGIIc3.js",
|
||||
"houseplan-assets/led-strip-editor-Bv9Lr7lm.js",
|
||||
"houseplan-assets/led-strip-runtime-BPf9yRa6.js",
|
||||
"houseplan-assets/live-interaction-runtime-BEBJqW9n.js",
|
||||
"houseplan-assets/moon-runtime-XZPiPFx9.js",
|
||||
"houseplan-assets/pdf-export-BEz8A-WF.js",
|
||||
"houseplan-assets/space-editor-CgOBkVzb.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-9plmRfcK.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"sha256": "f25cbcb0453279e3ac64eda67df52af100d96c54bb93cb657bc08c355e0484ed",
|
||||
"rawBytes": 688848,
|
||||
"gzipBytes": 192327,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-B0UGO6dl.js",
|
||||
"sha256": "c6fb53a9787befbd1d50f92321a49d14c7fed09c8f18ddfe5c91f70152c31098",
|
||||
"rawBytes": 695353,
|
||||
"gzipBytes": 194535,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
"houseplan-assets/backdrop-pick-BETofwqS.js",
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js",
|
||||
"houseplan-assets/zigbee-topology-HuGqPEnw.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
|
||||
"houseplan-assets/support-de-Det6AAMZ.js",
|
||||
"houseplan-assets/support-fr-Be4oWisL.js",
|
||||
"houseplan-assets/support-ru-CxDd1gON.js",
|
||||
"houseplan-assets/tools-de-CZBZcSYH.js",
|
||||
"houseplan-assets/tools-fr-CBGY_ULt.js",
|
||||
"houseplan-assets/tools-ru-CRsRYMok.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CikXQX3d.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"sha256": "4ea3584c8b2bfc86f96c587549ddce49eaf0b918923df2df8e98c9c0a548f835",
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-egRvu3Zt.js",
|
||||
"sha256": "9e47c290bddf33685e409dc3f9dab4bbdb3255ee8cd61e7b1bbe25e77cc4b67d",
|
||||
"rawBytes": 15886,
|
||||
"gzipBytes": 4908,
|
||||
"gzipBytes": 4905,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
"houseplan-assets/backdrop-pick-BETofwqS.js",
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"sha256": "2a0f9af108b8a39889f90d7119d8e7789716c9960ea8e28af38ada07cdc91a8c",
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-CKoAX7VK.js",
|
||||
"sha256": "912c854d66329607b8e8a9221d1ec7f6fa23fda5109e0d8249714dc7651b5b4c",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 3765,
|
||||
"gzipBytes": 3766,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CKlry6R8.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js",
|
||||
"houseplan-assets/zigbee-topology-HuGqPEnw.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CikXQX3d.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"sha256": "4150eb9120fdaafa745761242ff3285056ceaccd7b016a16364574b90e3e2cfe",
|
||||
"rawBytes": 52970,
|
||||
"gzipBytes": 17885,
|
||||
"path": "houseplan-assets/iso-scene-render-BGDGIIc3.js",
|
||||
"sha256": "def046dca0dde7f1767f1534376e2dd364420bbf72b79f99238d1f384701726c",
|
||||
"rawBytes": 36700,
|
||||
"gzipBytes": 12344,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"sha256": "66e70baa00f4c9470ac9a37a229652cf5c5585caa2364de4d467a1c2ebe24358",
|
||||
"path": "houseplan-assets/led-de-CspHDqq9.js",
|
||||
"sha256": "ce0635a6e7fa5069935162413de0289910cc108cd0ab3315b60e84e7a555f4cb",
|
||||
"rawBytes": 1907,
|
||||
"gzipBytes": 917,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-fr-Dj352qSL.js",
|
||||
"sha256": "11a43e1ca33604ceaa15825ef85a7ff168175a6d703556d48d9fa4f627a7bc72",
|
||||
"rawBytes": 1886,
|
||||
"gzipBytes": 903,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-ru-DLDnT8dh.js",
|
||||
"sha256": "fcc11ce3c09b73c3bae3695a00742a570dc02eede36297dc31f2a9c9ac931f89",
|
||||
"rawBytes": 2525,
|
||||
"gzipBytes": 1104,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-editor-Bv9Lr7lm.js",
|
||||
"sha256": "530be8614e700b055465929c4b1c5243c2b28ea65a5db9f0d0294b93804aefa1",
|
||||
"rawBytes": 21704,
|
||||
"gzipBytes": 7378,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/led-strip-geometry-Do0rAWqY.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/led-de-CspHDqq9.js",
|
||||
"houseplan-assets/led-fr-Dj352qSL.js",
|
||||
"houseplan-assets/led-ru-DLDnT8dh.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-field-CR9q2TG7.js",
|
||||
"sha256": "880dbd2787cdf317f8383a2f366e182833f070a6a71674d91cbbaf98dc650bb8",
|
||||
"rawBytes": 7594,
|
||||
"gzipBytes": 3197,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/led-strip-geometry-Do0rAWqY.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-geometry-Do0rAWqY.js",
|
||||
"sha256": "9bd7f5ec05e87d3bb51efa7539e011b9c2729f3b8269a2aaa53e63287a015e2b",
|
||||
"rawBytes": 6599,
|
||||
"gzipBytes": 2708,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/led-strip-runtime-BPf9yRa6.js",
|
||||
"sha256": "96eb916fad7a08069124828ed46df8c3aa07f1d7436c1db93fbe8ea37f46bd57",
|
||||
"rawBytes": 10720,
|
||||
"gzipBytes": 4214,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/led-strip-geometry-Do0rAWqY.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/led-strip-field-CR9q2TG7.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-BEBJqW9n.js",
|
||||
"sha256": "7e4f5768549792127c5646386dae42e3b2403700f5024793ae24a00cf8124fa0",
|
||||
"rawBytes": 8370,
|
||||
"gzipBytes": 3280,
|
||||
"gzipBytes": 3276,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"sha256": "244bce2d5211106846215be6a9d3dacda4c910d06020144f9ffd5e432c5e8c00",
|
||||
"path": "houseplan-assets/moon-runtime-XZPiPFx9.js",
|
||||
"sha256": "23bb6bed8d2581e3adb961ff968d687d3f1c5d3a5559ccccab9637388d2697a3",
|
||||
"rawBytes": 30292,
|
||||
"gzipBytes": 11716,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/namespace-language-yPTfzY6L.js",
|
||||
"sha256": "9793da70f9120828eeea6778ed00078499867508029def97e5b9711a346bd133",
|
||||
"rawBytes": 675,
|
||||
"gzipBytes": 410,
|
||||
"gzipBytes": 409,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"sha256": "548dced1a8a54f860dbe57e61019d50f058d3ca13abb738d750a3c107c88de8b",
|
||||
"path": "houseplan-assets/pdf-export-BEz8A-WF.js",
|
||||
"sha256": "75cb338d1b0c50ea2df97a9c22ce6dbd5054c116dade6c6e8dc990cb9ed237e9",
|
||||
"rawBytes": 236714,
|
||||
"gzipBytes": 109998,
|
||||
"gzipBytes": 109995,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-I3ipZ8Wz.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"sha256": "0580b9f6191edb664c21dee21e5263489eaf22876bcad67ef12e440abc03e28a",
|
||||
"rawBytes": 7378,
|
||||
"gzipBytes": 2879,
|
||||
"path": "houseplan-assets/settings-de-bncD5GI2.js",
|
||||
"sha256": "3bb52999bd61ee7c3bc83e39e0d85916e782f41097ed4e4b28c7997ce6ebbe66",
|
||||
"rawBytes": 8886,
|
||||
"gzipBytes": 3405,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"sha256": "f1bde9db7fe884aabd25130f783a84d8fe27bc9553923c17bcb9b82ee3976475",
|
||||
"rawBytes": 7620,
|
||||
"gzipBytes": 2842,
|
||||
"path": "houseplan-assets/settings-fr-DZU5lA4h.js",
|
||||
"sha256": "c2781260722675c46df4c7c654f657f42272dc1d98ab85514ef459b155dbcefc",
|
||||
"rawBytes": 9297,
|
||||
"gzipBytes": 3378,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-ru-CkNFmChi.js",
|
||||
"sha256": "94f96c4a15a3a82ca8be3066629734379dd14eb2ff9e8f6acf369da2378676cb",
|
||||
"rawBytes": 10304,
|
||||
"gzipBytes": 3325,
|
||||
"path": "houseplan-assets/settings-ru-DZ0CUv3G.js",
|
||||
"sha256": "7ef07dede0a04f5284813bf996766d218418b5cc5f2eed1d587181e46a16d682",
|
||||
"rawBytes": 12284,
|
||||
"gzipBytes": 3896,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-C_vpFucN.js",
|
||||
"sha256": "d45dbdfa17a1da9f45bfbca6d6c3cc98849fd5ebcc06c8f72fd6310b04b93aad",
|
||||
"path": "houseplan-assets/space-editor-CgOBkVzb.js",
|
||||
"sha256": "1be57add40d1843162a568193111673fac31c0446bb35065ec3ae893da2005e4",
|
||||
"rawBytes": 2619,
|
||||
"gzipBytes": 1124,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-Bs04cNeE.js",
|
||||
"sha256": "40765b538144e69a174b91f32c2f8f3ac6239f7fc0e65c40b4c6bc1e6c66940b",
|
||||
"rawBytes": 14862,
|
||||
"gzipBytes": 3425,
|
||||
"gzipBytes": 3421,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-9plmRfcK.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-D47J-OjO.js",
|
||||
"sha256": "909d2195fcfca30c2f1279e07e2d7506d470c3daec6fe864c97f7ecc4101e33d",
|
||||
"path": "houseplan-assets/summary-panel-metrics-GkRixX6s.js",
|
||||
"sha256": "850541d00b87dde57db4b0d90ec34203f1b1a06ddb743c53143208502f2f1f1b",
|
||||
"rawBytes": 2701,
|
||||
"gzipBytes": 1344,
|
||||
"gzipBytes": 1342,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
|
||||
"sha256": "e44860a1af9ec7ffc7ac289c8c76ed1d0dcf0177bd91bbfe1981fdd0d4a62c46",
|
||||
"rawBytes": 72959,
|
||||
"gzipBytes": 20242,
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-9plmRfcK.js",
|
||||
"sha256": "3d91e8ea0e0645d4606cfd4cdce4477fe60db8459e4773c22a2a808003251b11",
|
||||
"rawBytes": 73476,
|
||||
"gzipBytes": 20482,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/form-kit.styles-z4xUhaVc.js",
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-C_vpFucN.js",
|
||||
"houseplan-assets/summary-panel-metrics-D47J-OjO.js"
|
||||
"houseplan-assets/summary-panel-editor-Bs04cNeE.js",
|
||||
"houseplan-assets/summary-panel-metrics-GkRixX6s.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"sha256": "f01d2d00a169519f5f402e99e9c85c9f21a23aeee2a9605d4444910f3250d6de",
|
||||
"path": "houseplan-assets/support-de-Det6AAMZ.js",
|
||||
"sha256": "06ae57d6781493eef8a0dd8d4ee5b8eef4e6d5ccb39aef3ea1a8f355d2c0260a",
|
||||
"rawBytes": 6167,
|
||||
"gzipBytes": 2396,
|
||||
"gzipBytes": 2392,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"sha256": "e503a1b4e1b1ed31d8fa09bfe0cdc069a82c1d1627d40407472b336fbc98ccf5",
|
||||
"path": "houseplan-assets/support-fr-Be4oWisL.js",
|
||||
"sha256": "f06e6ab1a42d78304515ae12be4874284f68e281b6a33dedda620699025473ee",
|
||||
"rawBytes": 6333,
|
||||
"gzipBytes": 2432,
|
||||
"gzipBytes": 2427,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"sha256": "76742313c4b510163c380f8fd9c7fcaf1cc7f8bde6a41bcb0da48621a7c3a3b3",
|
||||
"path": "houseplan-assets/support-ru-CxDd1gON.js",
|
||||
"sha256": "3f3ad005fd3d67530d7c366c52f899ce6fb4b3b237f0b377db1b608257bfe74c",
|
||||
"rawBytes": 8376,
|
||||
"gzipBytes": 2876,
|
||||
"gzipBytes": 2873,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"sha256": "7c808f1139bb1c232627bfe884a579177ec653d16147d6b96066851893b312a4",
|
||||
"path": "houseplan-assets/tools-de-CZBZcSYH.js",
|
||||
"sha256": "1b986da32c053c5ff0417133f3b93dbd8b2fc8a14f3553b8e7e0cddbd49cb45b",
|
||||
"rawBytes": 4130,
|
||||
"gzipBytes": 1372,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/tools-fr-CBGY_ULt.js",
|
||||
"sha256": "b1afce35f46225af40595991682afb5a592f5cdb6b2cf89027b872221dbb4511",
|
||||
"rawBytes": 4136,
|
||||
"gzipBytes": 1355,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/tools-ru-CRsRYMok.js",
|
||||
"sha256": "44f3de3143171d949658f8bf2d2f124ae0900aab44a747f7d313bcb229691491",
|
||||
"rawBytes": 5633,
|
||||
"gzipBytes": 1693,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-de-BVr1ANny.js",
|
||||
"sha256": "6d41ab8cac204b22d51289aa2557ec3b51665caa9c9ad458c407909e80ed3fd6",
|
||||
"rawBytes": 2698,
|
||||
"gzipBytes": 1337,
|
||||
"gzipBytes": 1331,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"sha256": "6ee1530fd0bd7a8c476c79b83404c7d8a5b98cd0b86ee8a73048be75a70cc2c4",
|
||||
"path": "houseplan-assets/topology-fr-Di5BqM3k.js",
|
||||
"sha256": "ecbf695c0597d68015798b3db6a7c9bd44c68008f80e56ef5702b338ecbe5ced",
|
||||
"rawBytes": 2718,
|
||||
"gzipBytes": 1354,
|
||||
"gzipBytes": 1351,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-ru-BRKShmrQ.js",
|
||||
"sha256": "7fc89542fa860318d459ce1467a5a8931f3214a9d17f57c177ba01f2cfbbd3e8",
|
||||
"path": "houseplan-assets/topology-ru-BLuMP8mY.js",
|
||||
"sha256": "63b2768c199f75d8e91c6184ba231abe97e760ecdcc511e5d994f093ba7d3e22",
|
||||
"rawBytes": 3669,
|
||||
"gzipBytes": 1616,
|
||||
"gzipBytes": 1613,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"sha256": "313f71677ba0739d9c978185c2136e73e8037b770eb3f9ef8561f692d654406f",
|
||||
"path": "houseplan-assets/zigbee-topology-HuGqPEnw.js",
|
||||
"sha256": "0f38d6d5bedeb12415a0b0aafe6921cce71046bae479e0e8dc6beda285b6c02f",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 4299,
|
||||
"gzipBytes": 4297,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"houseplan-assets/topology-ru-BRKShmrQ.js"
|
||||
"houseplan-assets/topology-de-BVr1ANny.js",
|
||||
"houseplan-assets/topology-fr-Di5BqM3k.js",
|
||||
"houseplan-assets/topology-ru-BLuMP8mY.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CKlry6R8.js",
|
||||
"sha256": "39e4a250de0e67e80d8afc02c39c1a79e42d4b3e113107bb3817f4fa90cab1e2",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CikXQX3d.js",
|
||||
"sha256": "0b1a317b27d3071e923c6645392aee4cc997ea3d1969aafe030660d61cc221ff",
|
||||
"rawBytes": 3983,
|
||||
"gzipBytes": 1789,
|
||||
"gzipBytes": 1787,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js",
|
||||
"houseplan-assets/namespace-language-yPTfzY6L.js",
|
||||
"houseplan-assets/zigbee-topology-HuGqPEnw.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "d4b480db1327152db7b1459c45692ea6b72fb7ac3bccb525bc06adfe1aab3b8d",
|
||||
"sha256": "88cd6fc4c563502a0a2f27d60d8625e86478af9c8bef5c8689e0f2f8d7bb2e93",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 803,
|
||||
"gzipBytes": 800,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "5aaa677a9c85f4df0f176be31a91ebb5022f5ce129774919269bb0409c310233",
|
||||
"sha256": "7bd2cf052ed130e566a1d4b7b52c592ffa42f090c16a9814c487e3f246af39fc",
|
||||
"rawBytes": 6165,
|
||||
"gzipBytes": 2325,
|
||||
"gzipBytes": 2322,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
"houseplan-assets/houseplan-card-CJcZTAct.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{aD as e,l as o,aG as t,ez as s,A as a,eA as i,eB as l,eC as n,E as r,c}from"./houseplan-card-BYwTiVnu.js";class f 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,f={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")},h=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
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{aG as e,l as o,aJ as t,eD as s,A as a,eE as i,eF as l,eG as n,E as r,c}from"./houseplan-card-CJcZTAct.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=>f[e.name]||e.name}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(h.slice(0,3))}
|
||||
${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(h.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)}}f.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",f);
|
||||
${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);
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={"led.tool":"LED-Streifen","led.hint":"Zeichnen Sie den Streifen Punkt für Punkt entlang der Wandflächen oder über den Boden; er rastet am Raster und an Wandflächen ein. Danach wählen Sie das Gerät, das er darstellt.","led.start":"Klicken, um den Streifen zu beginnen","led.start_touch":"Tippen, um den Streifen zu beginnen","led.points":"Punkte: {n} · Umschalt — 45°-Schritte · Esc — fertig · Strg+Z — Punkt entfernen","led.points_touch":"Punkte: {n} · Werkzeug wechseln zum Beenden","led.finish":"Fertig","led.stopped":"Der Streifen stößt an eine Wand","led.unbound":"Streifen ist mit keinem Gerät verbunden","led.bind":"Gerät verbinden","led.change":"Gerät wechseln","led.later":"Später","led.unbind":"Trennen","led.delete":"Streifen löschen","led.settings":"Geräteeinstellungen","led.type_note":"LED-Streifen: Form im Geräte-Editor auf dem Plan bearbeiten","led.show_strip":"Als LED-Streifen zeigen","led.show_icon":"Als Symbol zeigen","led.cancel":"Abbrechen","led.taken":"Bereits mit einem anderen LED-Streifen verbunden","led.space":"Zuerst den LED-Streifen lösen: Er gehört zu seinem Bereich","led.limit_points":"Höchstens 50 Punkte pro Streifen","led.limit_strips":"Höchstens 50 Streifen pro Bereich","led.pick_title":"Gerät für den Streifen wählen","led.pick_new":"Neues Gerät…","led.pick_empty":"Auf diesem Plan gibt es noch keine freien Geräte.","led.saved":"LED-Streifen gespeichert","led.save_failed":"Streifen nicht gespeichert: {err}","led.history_draw":"LED-Streifen","led.history_shape":"Form des LED-Streifens","led.history_view":"Darstellung des Geräts","led.optimize_walls":"Streifen durch Wände: {n} ({spaces})"};const n="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,n as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={"led.tool":"Ruban LED","led.hint":"Tracez le ruban point par point le long des faces des murs ou sur le sol ; il s’aimante à la grille et aux faces des murs. Ensuite, choisissez l’appareil qu’il représente.","led.start":"Cliquez pour commencer le ruban","led.start_touch":"Touchez pour commencer le ruban","led.points":"Points : {n} · Maj — pas de 45° · Échap — terminer · Ctrl+Z — retirer le point","led.points_touch":"Points : {n} · changez d’outil pour terminer","led.finish":"Terminer","led.stopped":"Le ruban bute contre un mur","led.unbound":"Ruban non lié à un appareil","led.bind":"Lier un appareil","led.change":"Changer d’appareil","led.later":"Plus tard","led.unbind":"Délier","led.delete":"Supprimer le ruban","led.settings":"Réglages de l’appareil","led.type_note":"Ruban LED : modifiez sa forme sur le plan dans l’éditeur d’appareils","led.show_strip":"Afficher en ruban LED","led.show_icon":"Afficher en icône","led.cancel":"Annuler","led.taken":"Déjà lié à un autre ruban LED","led.space":"Détachez d’abord le ruban LED : il appartient à son espace","led.limit_points":"50 points au maximum par ruban","led.limit_strips":"50 rubans au maximum par espace","led.pick_title":"Choisissez l’appareil du ruban","led.pick_new":"Nouvel appareil…","led.pick_empty":"Aucun appareil libre sur ce plan pour l’instant.","led.saved":"Ruban LED enregistré","led.save_failed":"Ruban non enregistré : {err}","led.history_draw":"Ruban LED","led.history_shape":"Forme du ruban LED","led.history_view":"Représentation de l’appareil","led.optimize_walls":"Rubans traversant des murs : {n} ({spaces})"};const a="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,a as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={"led.tool":"LED-лента","led.hint":"Нарисуйте ленту по точкам вдоль граней стен или по полу — она прилипает к сетке и граням. После завершения выберите устройство, которое она показывает.","led.start":"Нажмите, чтобы начать ленту","led.start_touch":"Коснитесь, чтобы начать ленту","led.points":"Точек: {n} · Shift — шаг 45° · Esc — завершить · Ctrl+Z — убрать точку","led.points_touch":"Точек: {n} · смените инструмент, чтобы завершить","led.finish":"Завершить","led.stopped":"Лента упёрлась в стену","led.unbound":"Лента не привязана к устройству","led.bind":"Привязать устройство","led.change":"Сменить устройство","led.later":"Позже","led.unbind":"Отвязать","led.delete":"Удалить ленту","led.settings":"Настройки устройства","led.type_note":"LED-лента: форма редактируется на плане в редакторе устройств","led.show_strip":"Показывать LED-лентой","led.show_icon":"Показывать значком","led.cancel":"Отмена","led.taken":"Уже привязано к другой LED-ленте","led.space":"Сначала отвяжите LED-ленту: она принадлежит своему пространству","led.limit_points":"Не более 50 точек в ленте","led.limit_strips":"Не более 50 лент в пространстве","led.pick_title":"Выберите устройство для ленты","led.pick_new":"Новое устройство…","led.pick_empty":"На этом плане пока нет свободных устройств.","led.saved":"LED-лента сохранена","led.save_failed":"Лента не сохранена: {err}","led.history_draw":"LED-лента","led.history_shape":"Форма LED-ленты","led.history_view":"Вид устройства","led.optimize_walls":"Лент, проходящих сквозь стены: {n} ({spaces})"};const d="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,d as fingerprint};
|
||||
@@ -0,0 +1,47 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{et as e,eB as t,c6 as o,eI as s,eJ as i,ad as n,ed as a,O as r,eK as l,az as d,ee as c,aL as p,eL as u,eM as h,eN as f,A as m,eO as v}from"./houseplan-card-CJcZTAct.js";import{L as g,v as w,s as $,a as _,p as y,b as k,c as b,d as C,e as P}from"./led-strip-geometry-Do0rAWqY.js";const M="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";let F,L=null,U=null;function x(e,t){return L||U||F===e||""===F||(U=(void 0===F?import("./led-strip-field-CR9q2TG7.js"):import(new URL(`./led-strip-field-CR9q2TG7.js?${Date.now()}`,import.meta.url).href)).then(e=>{e.LED_FIELD_FINGERPRINT===s?L=e:F=""},()=>{F=e}).finally(()=>{U=null})),L||U?.then(()=>L&&t()),L}const D=e=>e.some(e=>e.glow&&"on"===e.state),R=e=>e.map(e=>[e[0],e[1]]),E="#383838";function A(s,i){if(!s)return null;const n=Array.isArray(s.masonryGeometry)?s.masonryGeometry:[],a=[...e(s.masonryGeometry),...s.opaqueBodies],r=[];for(const e of a)for(let t=0;t<e.length;t++){const o=e[t],s=e[(t+1)%e.length];o&&s&&r.push({a:[o[0],o[1]],b:[s[0],s[1]]})}const l=25,d=new Map;r.forEach((e,t)=>{const o=Math.floor(Math.min(e.a[0],e.b[0])/l),s=Math.floor(Math.max(e.a[0],e.b[0])/l),i=Math.floor(Math.min(e.a[1],e.b[1])/l),n=Math.floor(Math.max(e.a[1],e.b[1])/l);for(let e=o;e<=s;e++)for(let o=i;o<=n;o++){const s=`${e},${o}`,i=d.get(s);i?i.push(t):d.set(s,[t])}});const c=e=>{let t=1/0,o=1/0,s=-1/0,i=-1/0;for(const n of e)t=Math.min(t,n[0]),o=Math.min(o,n[1]),s=Math.max(s,n[0]),i=Math.max(i,n[1]);return[t,o,s,i]},p=(e,t)=>e[0]>=t[0]&&e[0]<=t[2]&&e[1]>=t[1]&&e[1]<=t[3],u=n.filter(e=>e?.[0]?.length).map(e=>({box:c(e[0]),poly:e})),h=s.opaqueBodies.map(e=>({box:c(e),body:e}));return{faces:r,inside:e=>u.some(({box:o,poly:s})=>p(e,o)&&t([e[0],e[1]],[s]))||h.some(({box:t,body:s})=>p(e,t)&&o([e[0],e[1]],s)),epsilon:i,near:e=>{const t=new Set;for(let o=Math.floor(e[0]/l);o<=Math.floor(e[2]/l);o++)for(let s=Math.floor(e[1]/l);s<=Math.floor(e[3]/l);s++)for(const e of d.get(`${o},${s}`)||[])t.add(e);return[...t].map(e=>r[e])}}}function I(e){const t=e.candidate?.sourceEid||e.device.primary||"",o=t?e.hass?.states?.[t]?.state:void 0,s="unavailable"===o||"unknown"===o||!!t&&!!e.hass&&!e.hass.states?.[t]&&!e.device.virtual,i=Number(e.device.marker?.glow_radius_cm),n=Number.isFinite(i)&&i>0?i/e.cellCm*e.gridPitch:e.defaultRadius,a=s?null:e.candidate?.appearance??null;return{strip:e.strip,device:e.device,state:s?"unavailable":a?"on":"off",glow:e.glow,appearance:a,radius:n}}function T(e,t){const o=(s=t.d,P*s);var s;return{t:o,path:b(R(e.strip.points),t.faces,o/2)}}function j(e){if(!e.views.length)return d``;const t=e.handlers,o=[...e.views].sort((e,t)=>e.strip.id<t.strip.id?1:e.strip.id>t.strip.id?-1:0),s=e.iso;return d`<g class="led-strips" data-hp-led-strips="${o.length}"
|
||||
data-hp-iso=${s?"raised":m}>
|
||||
${s?d`<defs><filter id="hp-led-iso-shadow" filterUnits="userSpaceOnUse"
|
||||
x="-100000" y="-100000" width="200000" height="200000">
|
||||
<feGaussianBlur stdDeviation="${s.shadow.sigma}"></feGaussianBlur></filter></defs>`:m}
|
||||
<style>
|
||||
.led-strip .led-focus { fill: none; stroke: transparent; }
|
||||
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
|
||||
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
|
||||
</style>
|
||||
${v(o,e=>e.strip.id,o=>{const{t:i,path:n}=T(o,e),a=y(n),r="unavailable"===o.state,l=r?"#9e9e9e":"on"===o.state&&!o.glow&&o.appearance?o.appearance.c:"#FFFFFF",c=2*k(i*e.perUnit)/(e.perUnit||1),p=o.device,u=t=>function(e,t,o){const s=e.currentTarget,i=s?.getScreenCTM?.();if(!i||o.views.length<2)return t;const n=i.inverse(),a=[n.a*e.clientX+n.c*e.clientY+n.e,n.b*e.clientX+n.d*e.clientY+n.f],r=o.views.map(e=>{const{t:t,path:s}=T(e,o);return{id:e.strip.id,points:s.points.map(e=>[e[0]*o.perUnit,e[1]*o.perUnit]),closed:s.closed,thicknessPx:t*o.perUnit}}),l=C([a[0]*o.perUnit,a[1]*o.perUnit],r);return o.views.find(e=>e.strip.id===l)||t}(t,o,e).device,h=s?`translate(0 ${-s.lift})`:m;return d`<g class="led-strip state-${o.state}" data-led-strip="${o.strip.id}"
|
||||
data-marker="${p.id}" data-state="${o.state}" data-closed="${n.closed?"true":"false"}">
|
||||
${s?d`<path class="led-iso-shadow" d="${a}" fill="none" stroke="black"
|
||||
stroke-opacity="${s.shadow.opacity}" stroke-width="${i}" stroke-linecap="round"
|
||||
stroke-linejoin="round" filter="url(#hp-led-iso-shadow)" pointer-events="none"
|
||||
transform="translate(${s.shadow.dx} ${s.shadow.dy-s.lift})"></path>
|
||||
${[1,.5].map(e=>d`<path class="led-iso-edge" d="${a}" fill="none" stroke="${s.edge}"
|
||||
stroke-width="${i}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"
|
||||
transform="translate(0 ${-s.lift+s.depth*e})"></path>`)}`:m}
|
||||
<g transform=${h}>
|
||||
<path class="led-outline" d="${a}" fill="none" stroke="${E}" stroke-width="${i}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
|
||||
<path class="led-core" d="${a}" fill="none" stroke="${l}" stroke-width="${i/2}"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-dasharray=${r?`${2*i} ${1.5*i}`:m}></path>
|
||||
<path class="led-focus" d="${a}" stroke-width="${1.6*i}" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true"></path>
|
||||
${t?d`<path class="led-hit" d="${a}" stroke-width="${c}"
|
||||
stroke-linecap="round" stroke-linejoin="round" pointer-events="stroke"
|
||||
role="button" tabindex="0" aria-label="${t.label(p)}"
|
||||
data-hp="device" data-id="${p.id}"
|
||||
@click=${e=>t.click(e,u(e))}
|
||||
@keydown=${e=>t.keydown(e,p)}
|
||||
@contextmenu=${e=>t.contextmenu(e,u(e))}
|
||||
@pointerdown=${e=>t.pointerdown(e,u(e))}
|
||||
@pointermove=${e=>t.pointermove(e,u(e))}
|
||||
@pointerup=${e=>t.pointerup(e,u(e))}
|
||||
@pointercancel=${e=>t.pointercancel(e,p)}
|
||||
@lostpointercapture=${e=>t.pointercancel(e,p)}
|
||||
@pointerover=${e=>t.pointerover(e,u(e))}
|
||||
@pointerleave=${()=>t.pointerleave()}
|
||||
@focus=${e=>t.focus(e,p)}
|
||||
@blur=${()=>t.blur(p)}></path>`:m}
|
||||
</g>
|
||||
</g>`})}
|
||||
</g>`}function B(e,t,o,s){return(t?e.find(e=>e.id===t):void 0)??(o?e.find(e=>s(o,e)):void 0)}function G(e){const t=new Map(e.devices.map(e=>[e.id,e])),o=g/e.cellCm*e.gridPitch,s=new Map(i({hass:e.hass,devices:e.devices,virtualLights:e.virtualLights,spaceId:e.space.id,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,defaultRadiusUnits:o,cellCm:e.cellCm,gridPitch:e.gridPitch,position:()=>({x:0,y:0})}).map(e=>[e.key,e])),r=[];for(const i of e.space.led_strips||[]){if(!1===i?.active||!i.marker||!w(i.points))continue;const a={...i,points:i.points.map(e=>[e[0]*n,e[1]*n])},l=t.get(a.marker);if(!l||l.space!==e.space.id)continue;if(l.hidden&&!e.showHidden)continue;const d=B(e.space.rooms,l.marker?.room_id,$(R(a.points)),e.inRoom);r.push(I({strip:a,device:l,candidate:s.get(`${e.space.id}|${l.id}`)??null,hass:e.hass,glow:!!d&&e.glowFor(d),defaultRadius:o,cellCm:e.cellCm,gridPitch:e.gridPitch}))}const l=_/e.cellCm*e.gridPitch;return{views:r,faces:A(e.scene,l),d:e.iconPct/100*a(e.space),scene:e.scene,polygons:e.polygons}}const H=new WeakMap;function N(e){H.delete(e),L?.releaseLedField(e)}function q(e){return{shapes:H.get(e)?.frame.views.length??0,...L?.ledFieldStats(e)??{visibility:0,sources:0,recomputes:0}}}function S(e,t,o){const s=t.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),i=s.length?e._lightBarriers(t,s.map(({room:e,poly:t})=>({r:e,poly:t}))):null,n=[t,e._renderDevices,e._renderPlanHass,o,i,e._mode,e._showAll],a=H.get(e);if(a&&a.key.every((e,t)=>e===n[t]))return a.frame;const d=e._config?.icon_size??2.5,c=G({space:t,devices:e._renderDevices,hass:e._renderPlanHass,virtualLights:e._virtualLights,defaultColor:e._fillColors.glow_light.c,paletteAlpha:e._fillColors.glow_light.a,cellCm:e._cellCm,gridPitch:e._gridPitch,iconPct:d>8?2.5:d,scene:i,polygons:s,glowFor:e=>l(o,e),inRoom:(t,o)=>e._pointInRoom(t,o),showHidden:"devices"===e._mode&&e._showAll});return!1!==e.isConnected&&H.set(e,{key:n,frame:c}),c}function O(e,t,o,s){const i=S(e,t,o),n=e._stageEl?.clientWidth,a="iso"===e._renderProjection&&"view"===e._mode,r=a?i.d*c:i.d,l="theme-dark"===p(e._renderPlanHass)?"dark":"light",m=!!e._isoLightFloors?.size,v=u(l,m),g=j({views:i.views,d:r,iso:a?{lift:f.lift*r,depth:f.depth*r,edge:h(E,l,m),shadow:{dx:v.dx*r,dy:v.dy*r,sigma:v.sigma*r,opacity:v.opacityWhite}}:null,faces:i.faces,perUnit:n&&s.w?n/s.w:1,handlers:"view"===e._mode?{click:(t,o)=>e._clickDevice(t,o),keydown:(t,o)=>e._keyDevice(t,o),contextmenu:(t,o)=>e._ctxDevice(t,o),pointerdown:(t,o)=>e._pointerDown(t,o),pointermove:(t,o)=>{const s=e._pointerMove(t,o);s&&e._showDeviceTip(t,s)},pointerup:(t,o)=>e._pointerUp(t,o),pointercancel:(t,o)=>e._pointerCancel(t,o),pointerover:(t,o)=>e._showDeviceTip(t,o),pointerleave:()=>e._clearPointerHover(),focus:(t,o)=>e._showDeviceFocusTip(t,o),blur:t=>e._hideDeviceFocusTip(t.id),label:e=>e.name}:null});return"plan"===e._mode||"decor"===e._mode?d`<g class="led-passive" opacity="0.45" pointer-events="none">${g}</g>`:g}function z(e,t,o){if(!1===e.isConnected)return d``;const s=S(e,t,o),i=s.scene&&(D(s.views)||L?.hasLedField(e))?L||x(t.id,()=>!1!==e.isConnected&&e.requestUpdate()):null;return i?i.renderLedField({views:s.views,scene:s.scene,polygons:s.polygons,faces:s.faces,spaceId:t.id,owner:e,requestUpdate:()=>e.requestUpdate(),isConnected:()=>!1!==e.isConnected}):d``}function W(e){const t=e.space.rooms.flatMap(e=>{const t=r(e);return t?[{room:e,poly:t}]:[]}),o=G({space:e.space,devices:e.devices,hass:e.hass,virtualLights:e.virtualLights,defaultColor:e.defaultColor,paletteAlpha:e.paletteAlpha,cellCm:e.cellCm,gridPitch:e.gridPitch,iconPct:e.iconPct,scene:e.scene,polygons:t,glowFor:e.glowFor,inRoom:e.inRoom,showHidden:!1}),s=e.live?o.views:o.views.map(e=>({...e,state:"off",appearance:null,glow:!1})),i=A({masonryGeometry:e.bodies.masonryGeometry,opaqueBodies:e.bodies.opaqueBodies},_/e.cellCm*e.gridPitch),n=e.scene&&e.live&&(D(s)||L?.hasLedField(e.owner))?x(e.space.id,e.ready):null;return d`${n?n.renderLedField({views:s,scene:e.scene,polygons:t,faces:i,spaceId:e.space.id,owner:e.owner,requestUpdate:e.ready,isConnected:()=>!0}):m}${j({views:s,d:o.d,faces:i,perUnit:e.perUnit,handlers:null})}`}export{M as LED_RUNTIME_FINGERPRINT,A as faceContext,x as ledField,G as ledFrame,S as ledFrameFor,q as ledStats,I as ledStripView,N as releaseLed,z as renderLedFieldFor,O as renderLedLayerFor,j as renderLedStripes,W as renderStaticLed,B as stripRoom};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{eQ as e,eR as a,eF as n}from"./houseplan-card-CJcZTAct.js";function d(n,d){return new e([{code:"en",dictionary:n},{code:"ru",loadDictionary:d.ru},{code:"de",loadDictionary:d.de},{code:"fr",loadDictionary:d.fr}],"edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7",console.warn,a)}function o(e){return function(e,a){return{state:n=>{const d=e.state(n);return"pending"!==d&&a.some(e=>"pending"===e.state(n))?"pending":d},dictionary:a=>e.dictionary(a),ensure:n=>Promise.all([e,...a].map(e=>e.ensure(n))).then(()=>{})}}(n,e)}export{d as n,o as s};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eE as e,eF as n,eB as a}from"./houseplan-card-BYwTiVnu.js";function c(a,c){return new e([{code:"en",dictionary:a},{code:"ru",loadDictionary:c.ru},{code:"de",loadDictionary:c.de},{code:"fr",loadDictionary:c.fr}],"a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",console.warn,n)}function o(e){return function(e,n){return{state:a=>{const c=e.state(a);return"pending"!==c&&n.some(e=>"pending"===e.state(a))?"pending":c},dictionary:n=>e.dictionary(n),ensure:a=>Promise.all([e,...n].map(e=>e.ensure(a))).then(()=>{})}}(a,e)}export{c as n,o as s};
|
||||
@@ -0,0 +1,7 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{aG as e,l as t,aJ as s,A as a,eE as o,eF as i,eG as n,E as l,eP as c,c as r}from"./houseplan-card-CJcZTAct.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!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}))}catch{this._spaces=[]}finally{this._spacesLoading=!1}}}get _lang(){return t(this.hass,this._config?.language)}get _schema(){const e=this._spaces||[];return[e.length?{name:"space",selector:{select:{mode:"dropdown",options:e}}}:{name:"space",selector:{text:{}}},{name:"title",selector:{text:{}}},{name:"fit",selector:{select:{mode:"dropdown",options:[{value:"content",label:s(this._lang,"editor.fit_content")},{value:"house",label:s(this._lang,"editor.fit_house")}]}}},{name:"show_button",selector:{boolean:{}}},{name:"button_label",selector:{text:{}}},{name:"button_target",selector:{text:{}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"light_pools",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}}]}render(){if(!this.hass||!this._config)return a;const e=o(this,i,t(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return l;this._loadSpaces();const h=this._lang,_={space:s(h,"editor.space"),title:s(h,"editor.title"),fit:s(h,"editor.framing"),show_button:s(h,"editor.show_button"),button_label:s(h,"editor.button_label"),button_target:s(h,"editor.button_target"),icon_size:s(h,"editor.icon_size"),show_temperature:s(h,"editor.show_temperature"),live_states:s(h,"editor.live_states"),light_pools:s(h,"editor.light_pools"),show_signal:s(h,"editor.show_signal")};return r`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{...this._config,fit:c(this._config.fit)}}
|
||||
.schema=${this._schema}
|
||||
.computeLabel=${e=>_[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`}_valueChanged(e){const t={...this._config||{},...e.detail.value};delete t.aspect_ratio;const s=new Event("config-changed",{bubbles:!0,composed:!0});s.detail={config:t},this.dispatchEvent(s)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-space-card-editor")||customElements.define("houseplan-space-card-editor",h);
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{c as a,A as e}from"./houseplan-card-BYwTiVnu.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-B0Dq0vzN.js";import"./form-kit.styles-v5DlO12L.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__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{c as a,A as e}from"./houseplan-card-CJcZTAct.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-9plmRfcK.js";import"./form-kit.styles-z4xUhaVc.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"
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{eH as e,N as t,b2 as n,L as i,ae as o,ac as r,c9 as s,eI as a,c8 as c,b1 as l,b0 as d,b3 as u,eJ as f,eK as m,cb as g,eL as v}from"./houseplan-card-BYwTiVnu.js";function y(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 o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(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:g(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 C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{e_ as e,O as t,b5 as n,M as i,af as o,ad as r,ce as s,e$ as a,cd as c,b4 as l,b3 as d,b6 as u,f0 as f,f1 as m,cg as g,f2 as v}from"./houseplan-card-CJcZTAct.js";function y(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 o=String(i.binding||"").indexOf(":");if(o<1)continue;const r=i.binding.slice(0,o),s=i.binding.slice(o+1),a="device"===r?e.registry.devices?.[s]:null,c="entity"===r?e.registry.entities?.[s]:null,l="device"===r?s:c?.device_id,d="device"===r?a?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,u="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||d||"",f=u&&e.areaToSpace[u]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===r&&n.liveEntities.has(s))&&t.add(l)}return t}function _(e,t){return e?.length?t?.length?v(e,t):e:t}function h(e,t){const n=l(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:d(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*p(l,d,u=h){try{let f=0;for(const m of d){const d=l.spaces.find(e=>String(e?.id)===m.id);if(!d)continue;const g=e(d,m),v=u(m,g);let y=null;for(const e of m.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(m.rooms,e.id,g.walls,g.openCuts,i,g.cellCm,o,r,v.roomGeom,v.multiWallNodes)||a;y=_(y,s(c,g.physicalBodies)),yield}const h=a(y||[],m.stairs);let p=h.next();for(;!p.done;)yield,p=h.next();y=p.value;const b=g.cellCm/o;f+=c(y)*b*b/1e4}return f}catch{return null}}function b(e,t,n=h){const i=p(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function S(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:g(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 C(e,t){const n=e?.states?.[t];if(!n)return null;const i=f(e,t);return i?m(i,String(n.attributes?.unit_of_measurement||"")):null}export{p as cleanFloorAreaSteps,y as representedHaDeviceIds,h as spaceWallGeometry,C as summaryEntityValue,S as summarySystemValue,b as totalCleanFloorAreaM2};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={"furn.title":"Möbelbibliothek","furn.symbol":"Symbol","furn.group_furniture":"Möbel","furn.group_appliance":"Geräte","furn.group_sanitary":"Sanitär","furn.group_other":"Sonstiges","furn.width":"Breite","furn.depth":"Tiefe","furn.back_to_categories":"Alle Kategorien","furn.pick_hint":"Wählen Sie eine Kategorie und dann ein Symbol.","furn.place_hint":"Klicken Sie auf den Plan — das Element wird an der nächsten Wand platziert. Mit Shift wird es frei platziert.","furn.cat_air_conditioner":"Klimaanlagen","furn.cat_armchair":"Sessel","furn.cat_bathtub":"Badewannen","furn.cat_bed":"Betten","furn.cat_bidet":"Bidets","furn.cat_boiler":"Warmwasserbereiter","furn.cat_chair":"Stühle","furn.cat_coffee_table":"Couchtische","furn.cat_computer":"Computer","furn.cat_cooktop":"Kochfelder","furn.cat_dining_table":"Esstische","furn.cat_dishwasher":"Geschirrspüler","furn.cat_dryer":"Wäschetrockner","furn.cat_exercise":"Fitnessgerät","furn.cat_fireplace":"Kamine","furn.cat_fridge":"Kühlschränke","furn.cat_hood":"Dunstabzugshaube","furn.cat_kitchen_cabinet":"Küchenschränke","furn.cat_kitchen_sink":"Küchenspülen","furn.cat_nightstand":"Schränke","furn.cat_oven":"Backofen","furn.cat_plant":"Pflanzen","furn.cat_rug":"Teppiche","furn.cat_shelving":"Regale","furn.cat_shower":"Duschen","furn.cat_sink":"Waschbecken","furn.cat_sofa":"Sofas","furn.cat_stairs":"Treppen","furn.cat_toilet":"Toiletten","furn.cat_tv":"Fernseher","furn.cat_wardrobe":"Kleiderschränke","furn.cat_washer":"Waschmaschinen","furn.cat_work_table":"Schreibtische","furn.sym_exercise":"Fitnessgerät","furn.sym_computer":"Computer","furn.sym_hood":"Dunstabzugshaube","furn.sym_oven":"Backofen","furn.sym_sofa":"Zweisitzer-Sofa","furn.sym_sofa_three_seat":"Dreisitzer-Sofa","furn.sym_sofa_corner_right":"Ecksofa rechts","furn.sym_armchair":"Polstersessel","furn.sym_armchair_office":"Bürostuhl","furn.sym_coffee_table":"Rechteckiger Couchtisch","furn.sym_coffee_table_round":"Runder Couchtisch","furn.sym_coffee_table_oval":"Ovaler Couchtisch","furn.sym_coffee_table_rounded":"Abgerundeter Couchtisch","furn.sym_table_dining":"Rechteckiger Esstisch","furn.sym_table_round":"Rundtisch","furn.sym_table_dining_oval":"Ovaler Esstisch","furn.sym_table_dining_rounded":"Abgerundeter Esstisch","furn.sym_chair":"Stuhl","furn.sym_chair_bar":"Barhocker","furn.sym_desk":"Rechteckiger Schreibtisch","furn.sym_desk_corner":"Eckschreibtisch","furn.sym_bed_double":"Doppelbett","furn.sym_bed_single":"Einzelbett","furn.sym_nightstand":"Nachttisch","furn.sym_cabinet_tv":"TV-Schrank","furn.sym_cabinet_shoe":"Schuhschrank","furn.sym_cabinet_sink":"Waschtischunterschrank","furn.sym_wardrobe":"Kleiderschrank","furn.sym_bookshelf":"Bücherschrank","furn.sym_wall_unit":"Schrankwand","furn.sym_kitchen_floor":"Küchen-Unterschrank","furn.sym_kitchen_floor_corner":"Küchen-Eckunterschrank","furn.sym_kitchen_wall":"Küchen-Hängeschrank","furn.sym_kitchen_wall_corner":"Küchen-Eckhängeschrank","furn.sym_shelf_floor":"Standregal","furn.sym_shelf_wall":"Wandregal","furn.sym_fridge":"Kühlschrank","furn.sym_stove":"Vierflammiges Kochfeld","furn.sym_cooktop_two":"Zweiflammiges Kochfeld","furn.sym_dishwasher":"Geschirrspüler","furn.sym_washer":"Waschmaschine","furn.sym_dryer":"Wäschetrockner","furn.sym_tv":"Fernseher mit Standfuß","furn.sym_tv_wall":"Wandfernseher","furn.sym_ac":"Klimaanlage","furn.sym_water_heater":"Wassererhitzer","furn.sym_toilet":"Stand-WC","furn.sym_toilet_built_in":"Wand-WC","furn.sym_bathtub":"Rechteckige Badewanne","furn.sym_bathtub_corner":"Eckbadewanne","furn.sym_shower":"Dusche","furn.sym_sink":"Waschbecken","furn.sym_kitchen_sink":"Einzel-Küchenspüle","furn.sym_kitchen_sink_double":"Doppel-Küchenspüle","furn.sym_bidet":"Stand-Bidet","furn.sym_bidet_built_in":"Wand-Bidet","furn.sym_stairs":"Treppe","furn.sym_fireplace":"Kamin","furn.sym_plant":"Pflanze","furn.sym_rug":"Teppich"};const r="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,r as fingerprint};
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={"furn.title":"Bibliothèque de mobilier","furn.symbol":"Symbole","furn.group_furniture":"Mobilier","furn.group_appliance":"Électroménager","furn.group_sanitary":"Sanitaires","furn.group_other":"Autre","furn.width":"Largeur","furn.depth":"Profondeur","furn.back_to_categories":"Toutes les catégories","furn.pick_hint":"Choisissez un symbole, puis cliquez sur le plan.","furn.place_hint":"Cliquez sur le plan — le meuble se place contre le mur le plus proche. Maj permet un placement libre.","furn.cat_air_conditioner":"Climatiseurs","furn.cat_armchair":"Fauteuils","furn.cat_bathtub":"Baignoires","furn.cat_bed":"Lits","furn.cat_bidet":"Bidets","furn.cat_boiler":"Chauffe-eau","furn.cat_chair":"Chaises","furn.cat_coffee_table":"Tables basses","furn.cat_computer":"Ordinateur","furn.cat_cooktop":"Plaques de cuisson","furn.cat_dining_table":"Tables à manger","furn.cat_dishwasher":"Lave-vaisselle","furn.cat_dryer":"Sèche-linge","furn.cat_exercise":"Équipement de sport","furn.cat_fireplace":"Cheminées","furn.cat_fridge":"Réfrigérateurs","furn.cat_hood":"Hotte","furn.cat_kitchen_cabinet":"Meubles de cuisine","furn.cat_kitchen_sink":"Éviers de cuisine","furn.cat_nightstand":"Meubles","furn.cat_oven":"Four","furn.cat_plant":"Plantes","furn.cat_rug":"Tapis","furn.cat_shelving":"Étagères","furn.cat_shower":"Douches","furn.cat_sink":"Lavabos","furn.cat_sofa":"Canapés","furn.cat_stairs":"Escaliers","furn.cat_toilet":"Toilettes","furn.cat_tv":"Téléviseurs","furn.cat_wardrobe":"Armoires","furn.cat_washer":"Lave-linge","furn.cat_work_table":"Bureaux","furn.sym_exercise":"Équipement de sport","furn.sym_computer":"Ordinateur","furn.sym_hood":"Hotte","furn.sym_oven":"Four","furn.sym_sofa":"Canapé","furn.sym_sofa_three_seat":"Canapé trois places","furn.sym_sofa_corner_right":"Canapé d'angle droit","furn.sym_armchair":"Fauteuil","furn.sym_armchair_office":"Chaise de bureau","furn.sym_coffee_table":"Table basse","furn.sym_coffee_table_round":"Table basse ronde","furn.sym_coffee_table_oval":"Table basse ovale","furn.sym_coffee_table_rounded":"Table basse arrondie","furn.sym_table_dining":"Table à manger","furn.sym_table_round":"Table ronde","furn.sym_table_dining_oval":"Table à manger ovale","furn.sym_table_dining_rounded":"Table à manger arrondie","furn.sym_chair":"Chaise","furn.sym_chair_bar":"Tabouret de bar","furn.sym_desk":"Bureau","furn.sym_desk_corner":"Bureau d'angle","furn.sym_bed_double":"Lit double","furn.sym_bed_single":"Lit simple","furn.sym_nightstand":"Table de chevet","furn.sym_cabinet_tv":"Meuble TV","furn.sym_cabinet_shoe":"Meuble à chaussures","furn.sym_cabinet_sink":"Meuble sous lavabo","furn.sym_wardrobe":"Armoire","furn.sym_bookshelf":"Bibliothèque","furn.sym_wall_unit":"Élément mural","furn.sym_kitchen_floor":"Module de cuisine bas","furn.sym_kitchen_floor_corner":"Module de cuisine bas d'angle","furn.sym_kitchen_wall":"Module de cuisine haut","furn.sym_kitchen_wall_corner":"Module de cuisine haut d'angle","furn.sym_shelf_floor":"Étagère au sol","furn.sym_shelf_wall":"Étagère murale","furn.sym_fridge":"Réfrigérateur","furn.sym_stove":"Cuisinière","furn.sym_cooktop_two":"Plaque à deux feux","furn.sym_dishwasher":"Lave-vaisselle","furn.sym_washer":"Lave-linge","furn.sym_dryer":"Sèche-linge","furn.sym_tv":"Téléviseur","furn.sym_tv_wall":"Téléviseur mural","furn.sym_ac":"Climatiseur","furn.sym_water_heater":"Chauffe-eau","furn.sym_toilet":"Toilettes","furn.sym_toilet_built_in":"Toilettes suspendues","furn.sym_bathtub":"Baignoire","furn.sym_bathtub_corner":"Baignoire d'angle","furn.sym_shower":"Douche","furn.sym_sink":"Lavabo","furn.sym_kitchen_sink":"Évier de cuisine","furn.sym_kitchen_sink_double":"Évier de cuisine double","furn.sym_bidet":"Bidet","furn.sym_bidet_built_in":"Bidet suspendu","furn.sym_stairs":"Escalier","furn.sym_fireplace":"Cheminée","furn.sym_plant":"Plante","furn.sym_rug":"Tapis"};const r="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,r as fingerprint};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,n as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={title:"Zigbee-Verbindungen",toggle:"Zigbee-Verbindungen beim Darüberfahren über ein Gerät anzeigen",hint:"Es werden nur beobachtete direkte Nachbarn angezeigt. Beim Darüberfahren werden keine Daten abgerufen.",help:"Zeigt, mit welchen Zigbee-Geräten jedes Gerät direkt verbunden ist. Die Linienfarbe ist die Verbindungsqualität (LQI) auf derselben Skala wie der LQI-Wert des Geräts: rot bei 40 und darunter, grün bei 180 und darüber. Eine gestrichelte Linie bedeutet, dass die Qualität nicht gemeldet wurde. Ein Pfeil zeigt auf das nächste Gerät auf dem Weg zum Koordinator: ein Pfeil führt hinaus, eingehende Pfeile sind die Geräte, die über dieses Gerät routen. Eine Linie ohne Pfeil ist ein Ersatznachbar. Das ist der Routenbaum, den House Plan bildet, nicht der Weg eines Pakets in diesem Moment: eine Beschriftung am Pfeilende bedeutet, dass das Ziel nicht auf diesem Plan liegt, und ein fehlender Pfeil, dass die Route unbekannt ist. Die Daten stammen aus dem letzten Laden und veralten; Verbindungen sind nur mit der Maus sichtbar.",help_aria:"Hilfe: Zigbee-Verbindungen",admin_only:"Topologiedaten sind nur für Home-Assistant-Administratoren verfügbar.",save_first:"Speichere diese Einstellung, bevor Topologiedaten geladen werden.",zha:"ZHA",zha_read:"ZHA-Daten lesen",zha_hint:"Liest den ZHA-Topologiecache; es wird kein Funkscan gestartet.",z2m:"Zigbee2MQTT",z2m_topics:"Basistopics (eines pro Zeile)",z2m_update:"Karte aktualisieren",z2m_warning:"Ein Netzwerkscan kann 10 Sekunden bis 2 Minuten dauern und Zigbee vorübergehend verlangsamen.",status_idle:"Daten nicht geladen",status_loading:"Wird geladen…",status_ready:"Empfangen: {time}",status_stale:"Empfangen: {time} · veraltet",status_partial:"Empfangen: {time} · einige Knoten fehlen",status_no_links:"Empfangen: {time} · keine beobachteten Verbindungen gefunden",error_permission:"Administratorrechte sind erforderlich.",error_unsupported:"Dieser Anbieter oder die Home-Assistant-API ist nicht verfügbar.",error_timeout:"Der Anbieter hat nicht rechtzeitig geantwortet.",error_invalid_topic:"Prüfe das Zigbee2MQTT-Basistopic.",error_invalid_payload:"Der Anbieter hat nicht unterstützte Topologiedaten geliefert.",error_provider:"Topologiedaten konnten nicht geladen werden.",remote_count:"+{n} in anderen Bereichen",route_device_not_on_plan:"Gerät ist nicht im Plan",route_coordinator_not_on_plan:"Koordinator ist nicht im Plan",route_other_space:"anderer Bereich"};const n="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,n as fingerprint};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,s as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={title:"Liens Zigbee",toggle:"Afficher les liens Zigbee au survol d’un appareil",hint:"Seuls les voisins directs observés sont affichés. Le survol ne lance aucune requête.",help:"Montre avec quels appareils Zigbee chaque appareil communique directement. La couleur de la ligne est la qualité du lien (LQI) sur la même échelle que l'indicateur LQI de l'appareil : rouge à 40 et en dessous, vert à 180 et au-dessus. Une ligne pointillée signifie que la qualité n'a pas été communiquée. Une flèche pointe vers l'appareil suivant sur le chemin du coordinateur : une flèche part, les flèches entrantes sont les appareils qui passent par celui-ci. Une ligne sans flèche est un voisin de secours. C'est l'arbre de routes que construit House Plan, pas le trajet d'un paquet à cet instant : une étiquette au bout d'une flèche signifie que sa cible n'est pas sur ce plan, et l'absence de flèche que la route est inconnue. Les données proviennent du dernier chargement et vieillissent ; les liens ne sont visibles qu'à la souris.",help_aria:"Aide : liens Zigbee",admin_only:"Les données de topologie sont réservées aux administrateurs Home Assistant.",save_first:"Enregistrez ce réglage avant de charger les données de topologie.",zha:"ZHA",zha_read:"Lire les données ZHA",zha_hint:"Lit la topologie en cache de ZHA sans lancer d’analyse radio.",z2m:"Zigbee2MQTT",z2m_topics:"Topics de base (un par ligne)",z2m_update:"Actualiser la carte",z2m_warning:"L’analyse de la carte réseau peut durer de 10 secondes à 2 minutes et réduire temporairement la réactivité de Zigbee.",status_idle:"Données non chargées",status_loading:"Chargement…",status_ready:"Reçu : {time}",status_stale:"Reçu : {time} · obsolète",status_partial:"Reçu : {time} · certains nœuds sont omis",status_no_links:"Reçu : {time} · aucun lien observé trouvé",error_permission:"Les droits administrateur sont requis.",error_unsupported:"Ce fournisseur ou l’API Home Assistant n’est pas disponible.",error_timeout:"Le fournisseur n’a pas répondu à temps.",error_invalid_topic:"Vérifiez le topic de base Zigbee2MQTT.",error_invalid_payload:"Le fournisseur a renvoyé des données de topologie non prises en charge.",error_provider:"Impossible de charger les données de topologie.",remote_count:"+{n} dans d’autres espaces",route_device_not_on_plan:"appareil absent du plan",route_coordinator_not_on_plan:"coordinateur absent du plan",route_other_space:"autre espace"};const s="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,s as fingerprint};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const t="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";export{e as dictionary,t as fingerprint};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";var e={title:"Связи Zigbee",toggle:"Показывать связи Zigbee при наведении на устройство",hint:"Показываются только наблюдаемые прямые соседи. Наведение ничего не запрашивает.",help:"Показывает, с какими устройствами Zigbee каждое связано напрямую. Цвет линии — качество связи (LQI) по той же шкале, что у показателя LQI устройства: красный при 40 и ниже, зелёный при 180 и выше. Пунктир — качество не сообщено. Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен. Данные из последней загрузки и устаревают, связи видны только при работе мышью.",help_aria:"Справка: связи Zigbee",admin_only:"Данные топологии доступны только администраторам Home Assistant.",save_first:"Сохраните настройку перед загрузкой данных топологии.",zha:"ZHA",zha_read:"Прочитать данные ZHA",zha_hint:"Читает сохранённую топологию ZHA и не запускает радио-сканирование.",z2m:"Zigbee2MQTT",z2m_topics:"Базовые топики (по одному в строке)",z2m_update:"Обновить карту",z2m_warning:"Сканирование карты сети занимает от 10 секунд до 2 минут и временно может снизить отзывчивость Zigbee.",status_idle:"Данные не загружены",status_loading:"Загрузка…",status_ready:"Получено: {time}",status_stale:"Получено: {time} · данные устарели",status_partial:"Получено: {time} · часть узлов не показана",status_no_links:"Получено: {time} · наблюдаемые связи не найдены",error_permission:"Нужны права администратора.",error_unsupported:"Провайдер или API Home Assistant недоступен.",error_timeout:"Провайдер не ответил вовремя.",error_invalid_topic:"Проверьте базовый топик Zigbee2MQTT.",error_invalid_payload:"Провайдер вернул неподдерживаемые данные топологии.",error_provider:"Не удалось загрузить данные топологии.",remote_count:"+{n} в других пространствах",route_device_not_on_plan:"устройства нет на плане",route_coordinator_not_on_plan:"координатора нет на плане",route_other_space:"другое пространство"};const a="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";export{e as dictionary,a as fingerprint};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";import{n as t,c as n}from"./zigbee-topology-DOAnbvlu.js";import{aM as e}from"./houseplan-card-BYwTiVnu.js";import"./namespace-language-zIuNvgGV.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};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";import{n as t,c as n}from"./zigbee-topology-HuGqPEnw.js";import{aP as e}from"./houseplan-card-CJcZTAct.js";import"./namespace-language-yPTfzY6L.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 m(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 h(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,h=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,h=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&m(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=m(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&&h?.(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,h as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e";try{await import("./houseplan-assets/houseplan-card-BYwTiVnu.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__="edee13342c3a392925263abcd396fef8a2b8daae1359d1cdb8f0a4a9c1aebdd7";try{await import("./houseplan-assets/houseplan-card-CJcZTAct.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)}
|
||||
|
||||
@@ -35,6 +35,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .led_strips import led_strip_link_report, plan_only_strips, unbind_strips
|
||||
from .radar_validation import validate_marker_radars
|
||||
from .store import HouseplanData
|
||||
from .validation import (
|
||||
@@ -294,6 +295,10 @@ def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
projected["decor"] = [
|
||||
_project_plan_only_decor(shape) for shape in space.get("decor") or []
|
||||
]
|
||||
led_strips = plan_only_strips(space)
|
||||
if led_strips is not None:
|
||||
# #780: plan-only keeps the strip geometry and never a device link.
|
||||
projected["led_strips"] = led_strips
|
||||
if "stairs" in space:
|
||||
projected["stairs"] = [
|
||||
_pick_fields(stair, (
|
||||
@@ -694,6 +699,12 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
# before an import token can be issued.
|
||||
if model > 0:
|
||||
config_candidate["model_version"] = model
|
||||
# #780: the schema step unbinds strips whose marker is not in the
|
||||
# document; count them before it does, for the import summary.
|
||||
unbound_led_strips = (
|
||||
led_strip_link_report(config_candidate)["unbound"]
|
||||
if isinstance(config_candidate, dict) else 0
|
||||
)
|
||||
config = CONFIG_SCHEMA(config_candidate)
|
||||
config.pop("model_version", None)
|
||||
except (vol.Invalid, TypeError, ValueError) as err:
|
||||
@@ -730,6 +741,10 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
**(document.get("transfer") or {}),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
}
|
||||
# Computed here, never trusted from the file.
|
||||
document["transfer"].pop("unbound_led_strips", None)
|
||||
if unbound_led_strips:
|
||||
document["transfer"]["unbound_led_strips"] = unbound_led_strips
|
||||
if plan_only:
|
||||
_validate_plan_only_document(document, config, layout, placement)
|
||||
if len(json.dumps(
|
||||
@@ -1608,6 +1623,14 @@ def build_space_merge(
|
||||
marker.pop("value_source", None)
|
||||
dropped_marker_links += 1
|
||||
|
||||
# #780: an LED strip follows its marker through the same id remap; a
|
||||
# marker that was skipped or virtualised by the duplicate policy is not
|
||||
# transferred, so the strip arrives unbound (geometry kept, no device).
|
||||
unbound_led_strips = unbind_strips(space, remap={
|
||||
old_id: new_id for old_id, new_id in marker_map.items()
|
||||
if old_id not in virtualized_targets
|
||||
})
|
||||
|
||||
output_markers_by_id = {
|
||||
str(marker.get("id")): marker for marker in output_markers
|
||||
if marker.get("id") is not None
|
||||
@@ -1709,6 +1732,7 @@ def build_space_merge(
|
||||
"virtualized": virtualized,
|
||||
"orphan_markers": len(output_markers) - len(marker_map),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
"unbound_led_strips": unbound_led_strips,
|
||||
"repaired_target_refs": repaired_target_refs,
|
||||
"preserved_unresolved_refs": sum(
|
||||
int(value) for value in reference_report["preservedUnresolved"].values()
|
||||
@@ -1914,6 +1938,14 @@ def _materialize_import_candidate(
|
||||
details = {
|
||||
"dropped_marker_links": _transfer_dropped_marker_links(prepared),
|
||||
}
|
||||
# #780: strips unbound by the document itself (counted while parsing)
|
||||
# plus those the transfer unbound (space merge) or the schema step will.
|
||||
transfer = prepared.get("transfer") if isinstance(prepared.get("transfer"), dict) else {}
|
||||
details["unbound_led_strips"] = (
|
||||
int(details.get("unbound_led_strips", 0))
|
||||
+ int(transfer.get("unbound_led_strips", 0) or 0)
|
||||
+ led_strip_link_report(config)["unbound"]
|
||||
)
|
||||
try:
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""LED strips: stored shape, link normalisation and transfer rules (#780).
|
||||
|
||||
A strip is optional geometry of a space::
|
||||
|
||||
space.led_strips = [{"id": str, "points": [[x, y], ...],
|
||||
"marker": str | None, "active": bool (optional)}]
|
||||
|
||||
``active`` is the representation of the bound marker: absent/``True`` draws
|
||||
the strip, ``False`` keeps the shape hidden while the same marker is shown as
|
||||
an ordinary icon. It is neither ``marker.hidden`` nor the power state.
|
||||
|
||||
The module is pure (voluptuous only), so the schema, the write-path
|
||||
normalisation and the import/plan-only projections are exercised by the pure
|
||||
pytest suite without Home Assistant.
|
||||
|
||||
Write-path order (ТЗ #780 §9):
|
||||
|
||||
1. structure, types and limits (``LED_STRIPS_SCHEMA`` inside ``SPACE_SCHEMA``);
|
||||
2. per-space invariants and duplicate links — **before** any normalisation, so
|
||||
two strips pointing at the same missing id are still a conflict;
|
||||
3. normalisation: a link to a marker that is not live in the resulting config
|
||||
becomes an unbound strip (``marker: None, active: True``) with its id and
|
||||
points intact — a client that does not know about strips may delete a bound
|
||||
marker and its save must not fail; a live marker with an empty ``space``
|
||||
adopts the strip's space;
|
||||
4. referential check: a live marker with a non-empty foreign ``space``
|
||||
rejects the whole write.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
MAX_LED_STRIPS = 50
|
||||
MAX_LED_POINTS = 50
|
||||
LED_ID_MAX = 64
|
||||
LED_MARKER_MAX = 500
|
||||
CANVAS_LIMIT = 5000.0
|
||||
|
||||
LED_KEY = "led_strips"
|
||||
|
||||
|
||||
def _coordinate(value: Any) -> float:
|
||||
"""A stored coordinate: a real finite number, never a string or a bool."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise vol.Invalid("LED strip coordinate must be a number")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise vol.Invalid("LED strip coordinate must be finite")
|
||||
if abs(number) > CANVAS_LIMIT:
|
||||
raise vol.Invalid("LED strip coordinate is outside the canvas")
|
||||
return number
|
||||
|
||||
|
||||
def _strict_bool(value: Any) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise vol.Invalid("LED strip active must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _marker_ref(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value or len(value) > LED_MARKER_MAX:
|
||||
raise vol.Invalid("LED strip marker must be a non-empty string or null")
|
||||
return value
|
||||
|
||||
|
||||
def _strip_id(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > LED_ID_MAX:
|
||||
raise vol.Invalid("LED strip id must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
_POINT = vol.All([_coordinate], vol.Length(min=2, max=2))
|
||||
|
||||
LED_STRIP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("id"): _strip_id,
|
||||
vol.Required("points"): vol.All(
|
||||
list, vol.Length(min=2, max=MAX_LED_POINTS), [_POINT],
|
||||
),
|
||||
vol.Optional("marker", default=None): _marker_ref,
|
||||
vol.Optional("active"): _strict_bool,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
LED_STRIPS_SCHEMA = vol.All(list, vol.Length(max=MAX_LED_STRIPS), [LED_STRIP_SCHEMA])
|
||||
|
||||
|
||||
def _same(a: list[Any], b: list[Any]) -> bool:
|
||||
return float(a[0]) == float(b[0]) and float(a[1]) == float(b[1])
|
||||
|
||||
|
||||
def strip_is_closed(points: list[Any]) -> bool:
|
||||
return len(points) >= 4 and _same(points[0], points[-1])
|
||||
|
||||
|
||||
def polyline_length(points: list[Any]) -> float:
|
||||
total = 0.0
|
||||
for index in range(1, len(points)):
|
||||
ax, ay = points[index - 1]
|
||||
bx, by = points[index]
|
||||
total += math.hypot(float(bx) - float(ax), float(by) - float(ay))
|
||||
return total
|
||||
|
||||
|
||||
def _distinct_vertices(points: list[Any]) -> int:
|
||||
seen: set[tuple[float, float]] = set()
|
||||
for point in points:
|
||||
seen.add((float(point[0]), float(point[1])))
|
||||
return len(seen)
|
||||
|
||||
|
||||
def validate_strip_shape(strip: dict[str, Any]) -> None:
|
||||
"""Geometry invariants that the type schema cannot express."""
|
||||
points = strip["points"]
|
||||
if _distinct_vertices(points) < 2 or not polyline_length(points) > 0:
|
||||
raise vol.Invalid("LED strip needs at least two distinct points")
|
||||
if _same(points[0], points[-1]) and len(points) > 2:
|
||||
# A repeated first point closes the strip: three distinct vertices.
|
||||
if _distinct_vertices(points[:-1]) < 3:
|
||||
raise vol.Invalid("a closed LED strip needs three distinct vertices")
|
||||
if strip.get("active") is False and not isinstance(strip.get("marker"), str):
|
||||
raise vol.Invalid("a hidden LED strip shape must belong to a marker")
|
||||
|
||||
|
||||
class LedStripLinkError(ValueError):
|
||||
"""A write that cannot be normalised: duplicate or foreign link."""
|
||||
|
||||
code = "invalid_led_strip"
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
# Marker ids may carry user-controlled HA identifiers: a stable
|
||||
# message without the value, like DuplicateMarkerIdError.
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _live_markers(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
live: dict[str, dict[str, Any]] = {}
|
||||
for marker in config.get("markers") or []:
|
||||
if not isinstance(marker, dict) or marker.get("removed") is True:
|
||||
continue
|
||||
marker_id = marker.get("id")
|
||||
if isinstance(marker_id, str) and marker_id and marker_id not in live:
|
||||
live[marker_id] = marker
|
||||
return live
|
||||
|
||||
|
||||
def _strips(config: dict[str, Any]) -> Iterator[tuple[dict[str, Any], dict[str, Any]]]:
|
||||
for space in config.get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if isinstance(strip, dict):
|
||||
yield space, strip
|
||||
|
||||
|
||||
def preserve_led_strips(config: dict[str, Any], previous: dict[str, Any] | None) -> int:
|
||||
"""An ordinary writer that omits ``led_strips`` keeps the stored shapes (#780 r1 H1).
|
||||
|
||||
A previous frontend does not know the field and sends every space without
|
||||
it; the omission is not a deletion. A new client deletes shapes by sending
|
||||
an explicit list (``[]`` removes all of them). Applies to spaces that keep
|
||||
their id; a space the writer removed takes its strips with it. Runs on the
|
||||
ordinary write path before normalisation, so a preserved link to a marker
|
||||
the same write deleted becomes an unbound strip by the usual rule.
|
||||
Returns how many spaces received their stored shapes back.
|
||||
"""
|
||||
stored = {
|
||||
space.get("id"): space[LED_KEY]
|
||||
for space in (previous or {}).get("spaces") or []
|
||||
if isinstance(space, dict) and isinstance(space.get(LED_KEY), list) and space[LED_KEY]
|
||||
}
|
||||
restored = 0
|
||||
for space in config.get("spaces") or []:
|
||||
if not isinstance(space, dict) or LED_KEY in space:
|
||||
continue
|
||||
shapes = stored.get(space.get("id"))
|
||||
if shapes:
|
||||
space[LED_KEY] = copy.deepcopy(shapes)
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
def led_strip_link_report(config: dict[str, Any]) -> dict[str, int]:
|
||||
"""What the normaliser would change, without changing anything.
|
||||
|
||||
The write path answers the client with these counters so a new client
|
||||
knows to re-read; an old client ignores the extra keys.
|
||||
"""
|
||||
live = _live_markers(config)
|
||||
unbound = adopted = 0
|
||||
for _space, strip in _strips(config):
|
||||
ref = strip.get("marker")
|
||||
if not isinstance(ref, str) or not ref:
|
||||
continue
|
||||
marker = live.get(ref)
|
||||
if marker is None:
|
||||
unbound += 1
|
||||
elif marker.get("space") in (None, ""):
|
||||
adopted += 1
|
||||
return {"unbound": unbound, "space_adopted": adopted}
|
||||
|
||||
|
||||
def normalize_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and normalise every strip of a whole configuration in place.
|
||||
|
||||
Runs after the type schema and the coordinate canonicalisation, so the
|
||||
shape invariants judge the stored numbers.
|
||||
"""
|
||||
taken: set[str] = set()
|
||||
for space in config.get("spaces") or []:
|
||||
if not isinstance(space, dict):
|
||||
continue
|
||||
ids: set[str] = set()
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if strip["id"] in ids:
|
||||
raise vol.Invalid("LED strip ids must be unique within a space")
|
||||
ids.add(strip["id"])
|
||||
validate_strip_shape(strip)
|
||||
ref = strip.get("marker")
|
||||
if isinstance(ref, str):
|
||||
if ref in taken:
|
||||
raise LedStripLinkError("a marker is bound to more than one LED strip")
|
||||
taken.add(ref)
|
||||
live = _live_markers(config)
|
||||
for space, strip in _strips(config):
|
||||
ref = strip.get("marker")
|
||||
if not isinstance(ref, str):
|
||||
strip["marker"] = None
|
||||
continue
|
||||
marker = live.get(ref)
|
||||
if marker is None:
|
||||
strip["marker"] = None
|
||||
strip["active"] = True
|
||||
continue
|
||||
owner = marker.get("space")
|
||||
if owner in (None, ""):
|
||||
marker["space"] = space["id"]
|
||||
elif owner != space["id"]:
|
||||
raise LedStripLinkError("an LED strip is bound to a marker of another space")
|
||||
return config
|
||||
|
||||
|
||||
def config_led_strip_links(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""voluptuous step for CONFIG_SCHEMA: ``LedStripLinkError`` → ``vol.Invalid``."""
|
||||
try:
|
||||
return normalize_led_strip_links(config)
|
||||
except LedStripLinkError as err:
|
||||
raise vol.Invalid(f"{LedStripLinkError.code}: {err.reason}") from err
|
||||
|
||||
|
||||
def unbind_strips(space: dict[str, Any], keep: set[str] | None = None,
|
||||
remap: dict[str, str] | None = None) -> int:
|
||||
"""Transfer rule for imports/copies: remap a link or make the strip unbound.
|
||||
|
||||
``remap`` maps the source marker id to the id it received in the target;
|
||||
a marker that was not transferred (skipped duplicate, plan-only, dropped)
|
||||
leaves an unbound strip with ``active: True``. Returns how many strips
|
||||
became unbound, for the import summary.
|
||||
"""
|
||||
unbound = 0
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if not isinstance(strip, dict):
|
||||
continue
|
||||
ref = strip.get("marker")
|
||||
if not isinstance(ref, str) or not ref:
|
||||
strip["marker"] = None
|
||||
continue
|
||||
target = (remap or {}).get(ref)
|
||||
if target is None and keep is not None and ref in keep:
|
||||
target = ref
|
||||
if target is None:
|
||||
strip["marker"] = None
|
||||
strip["active"] = True
|
||||
unbound += 1
|
||||
else:
|
||||
strip["marker"] = target
|
||||
return unbound
|
||||
|
||||
|
||||
def plan_only_strips(space: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
"""Plan-only export: geometry stays, device links never leak."""
|
||||
if LED_KEY not in space:
|
||||
return None
|
||||
projected = []
|
||||
for strip in space.get(LED_KEY) or []:
|
||||
if not isinstance(strip, dict):
|
||||
continue
|
||||
projected.append({
|
||||
"id": strip.get("id"),
|
||||
"points": [list(point) for point in strip.get("points") or []],
|
||||
"marker": None,
|
||||
"active": True,
|
||||
})
|
||||
return projected
|
||||
|
||||
|
||||
def led_strip_counts(config: dict[str, Any]) -> dict[str, int]:
|
||||
"""Support-package counters: no coordinates, names or HA identifiers."""
|
||||
total = unbound = hidden = 0
|
||||
for _space, strip in _strips(config):
|
||||
total += 1
|
||||
if not isinstance(strip.get("marker"), str):
|
||||
unbound += 1
|
||||
elif strip.get("active") is False:
|
||||
hidden += 1
|
||||
return {"led_strips": total, "led_strips_unbound": unbound, "led_strips_hidden": hidden}
|
||||
@@ -20,5 +20,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.78.0"
|
||||
"version": "1.79.0-beta.6"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import (
|
||||
MAX_SUPPORT_ATTACHMENT_BYTES,
|
||||
PLAN_MODEL_VERSION,
|
||||
)
|
||||
from .led_strips import led_strip_counts
|
||||
|
||||
PACKAGE_FORMAT = "houseplan-support-package"
|
||||
PACKAGE_VERSION = 1
|
||||
@@ -158,6 +159,9 @@ def _global_settings(value: object) -> dict[str, Any]:
|
||||
volumetric_view = value.get("volumetric_view")
|
||||
if isinstance(volumetric_view, bool):
|
||||
out["volumetric_view"] = volumetric_view
|
||||
moon = value.get("moon")
|
||||
if isinstance(moon, bool):
|
||||
out["moon"] = moon
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
palette = {
|
||||
@@ -454,10 +458,21 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
"binding": dict(sorted(bindings.items())),
|
||||
},
|
||||
# #780: counters only — no coordinates, room names or HA identifiers.
|
||||
"led_strips": _led_strip_summary(spaces),
|
||||
"layout_entries": len(layout) if isinstance(layout, dict) else 0,
|
||||
}
|
||||
|
||||
|
||||
def _led_strip_summary(spaces: list[Any]) -> dict[str, int]:
|
||||
counts = led_strip_counts({"spaces": [space for space in spaces if isinstance(space, dict)]})
|
||||
return {
|
||||
"total": counts["led_strips"],
|
||||
"unbound": counts["led_strips_unbound"],
|
||||
"hidden": counts["led_strips_hidden"],
|
||||
}
|
||||
|
||||
|
||||
def _binding_kind(value: object) -> str:
|
||||
text = str(value or "")
|
||||
if text == "virtual":
|
||||
|
||||
@@ -17,6 +17,11 @@ from custom_components.houseplan.coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
canonicalize_position,
|
||||
)
|
||||
from custom_components.houseplan.led_strips import (
|
||||
LED_STRIPS_SCHEMA,
|
||||
config_led_strip_links,
|
||||
preserve_led_strips,
|
||||
)
|
||||
from custom_components.houseplan.vacuum_routes import validate_marker_routes
|
||||
|
||||
# ---------- limits and extension sets ----------
|
||||
@@ -1877,6 +1882,8 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
# (HP-1454-05): relying on a modern client to strip an unbounded legacy
|
||||
# list is not a limit, it is a hope. `Remove` returns the key stripped.
|
||||
vol.Remove("segments"): object,
|
||||
# #780: optional LED strip shapes of the space (led_strips.py).
|
||||
vol.Optional("led_strips"): LED_STRIPS_SCHEMA,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
), _space_geometry_invariants)
|
||||
@@ -2268,13 +2275,16 @@ def prepare_ordinary_summary_candidate(
|
||||
readable_entity_ids: set[str],
|
||||
normalize,
|
||||
) -> dict:
|
||||
"""Apply the one summary-panel contract shared by ordinary config writers.
|
||||
"""Apply the one preservation contract shared by ordinary config writers.
|
||||
|
||||
`normalize` owns the writer-specific schema/migration sequence. Preservation
|
||||
must happen before it; change-aware reference validation must happen after
|
||||
it. Authoritative full import/restore intentionally does not use this helper.
|
||||
Preserved: the summary panel namespace (#437) and the LED strip shapes of a
|
||||
space an older writer sent without the field (#780).
|
||||
"""
|
||||
preserve_summary_panel_namespace(candidate, previous)
|
||||
preserve_led_strips(candidate, previous)
|
||||
checked = normalize(candidate)
|
||||
validate_summary_panel_references(checked, previous, readable_entity_ids)
|
||||
return checked
|
||||
@@ -2313,6 +2323,8 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
# #649: installation-wide 2.5D View; absent/false keeps Flat.
|
||||
vol.Optional("volumetric_view"): bool,
|
||||
# #661: the moon on the "Follow the Sun" background; absent/false = off.
|
||||
vol.Optional("moon"): bool,
|
||||
vol.Optional("summary_panel"): SUMMARY_PANEL_WIRE_SCHEMA,
|
||||
vol.Optional("radar"): vol.Schema({}, extra=vol.ALLOW_EXTRA),
|
||||
# Removed from the UI/runtime in 2026-08-08. Keep accepting the
|
||||
@@ -2351,4 +2363,7 @@ CONFIG_SCHEMA = vol.All(
|
||||
),
|
||||
canonicalize_config_geometry,
|
||||
_config_wall_segment_invariants,
|
||||
# #780: after canonicalisation — shape invariants judge stored numbers;
|
||||
# orphan links become unbound strips, foreign/duplicate links reject.
|
||||
config_led_strip_links,
|
||||
)
|
||||
|
||||
@@ -70,6 +70,7 @@ from .import_export import (
|
||||
revalidate_candidate,
|
||||
)
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .led_strips import led_strip_link_report, preserve_led_strips
|
||||
from .plans import (
|
||||
QuotaError,
|
||||
collect_attachments,
|
||||
@@ -1681,6 +1682,14 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
|
||||
radar_registry = radar_registry_evidence(hass)
|
||||
|
||||
# #780: what the write path will normalise in LED strip links (an
|
||||
# orphan becomes unbound, an empty marker.space adopts the strip's
|
||||
# space). Reported back so a new client re-reads; old ones ignore it.
|
||||
# An older writer omits the field: its stored shapes are kept first
|
||||
# (r1 H1), so the report also counts their links.
|
||||
preserve_led_strips(msg["config"], data.get("config"))
|
||||
led_report = led_strip_link_report(msg["config"])
|
||||
|
||||
def _validate_config_cpu():
|
||||
def _normalize(candidate):
|
||||
validate_wall_model_transition(candidate, data.get("config"))
|
||||
@@ -1789,7 +1798,10 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
from .repairs import async_check_plan_files
|
||||
|
||||
hass.async_create_task(async_check_plan_files(hass, entry))
|
||||
connection.send_result(msg["id"], {"ok": True, "rev": new_rev})
|
||||
result: dict[str, Any] = {"ok": True, "rev": new_rev}
|
||||
if any(led_report.values()):
|
||||
result["led_strips"] = led_report
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
# ---------------- whole-plan maintenance ----------------
|
||||
|
||||
@@ -22,7 +22,7 @@ const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (![
|
||||
'large-house-v1', 'large-house-isometric-v1', ISOMETRIC_STAGE3_DENSE_PROFILE,
|
||||
'large-house-plan-snap-v1', 'large-house-interaction-v1',
|
||||
'large-house-plan-snap-v1', 'large-house-interaction-v1', 'large-house-isometric-backdrop-v1',
|
||||
].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
|
||||
@@ -30,7 +30,8 @@ const allowStage2Base = process.argv.includes('--allow-stage2-base');
|
||||
if (allowStage2Base && !stage3Dense)
|
||||
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
|
||||
const requireStage3 = stage3Dense && !allowStage2Base;
|
||||
const isometric = profile === 'large-house-isometric-v1' || stage3Dense;
|
||||
const backdrop = profile === 'large-house-isometric-backdrop-v1';
|
||||
const isometric = profile === 'large-house-isometric-v1' || stage3Dense || backdrop;
|
||||
const planSnap = profile === 'large-house-plan-snap-v1';
|
||||
const interaction = profile === 'large-house-interaction-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
@@ -42,7 +43,20 @@ const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-fa
|
||||
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
|
||||
const requiresStairs = existsSync(resolve(targetRoot, 'src/stairs.ts'));
|
||||
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
|
||||
const fixtureCounts = stage3Dense ? fixture.counts : LARGE_HOUSE_COUNTS;
|
||||
if (backdrop) {
|
||||
// #743: the 2.5D twin with a backdrop image on every floor, so the imagePlan
|
||||
// path (#739 K1) is measured at all. One shipped picture (demo/srv/assets
|
||||
// since v1.14.0, present in every base) under a URL of its own per floor, so
|
||||
// each floor decodes its own image; plan_aspect 1 lays it over the whole
|
||||
// plan square and leaves the room geometry alone. The variant lives here, as
|
||||
// plan-snap's does: demo/fixtures/** and the bundle fingerprint stay as they are.
|
||||
for (const space of fixture.config.spaces) {
|
||||
space.plan_url = `/assets/f1.svg?${space.id}`;
|
||||
space.plan_aspect = 1;
|
||||
}
|
||||
fixture.counts = { ...fixture.counts, backdrops: fixture.config.spaces.length };
|
||||
}
|
||||
const fixtureCounts = stage3Dense || backdrop ? fixture.counts : LARGE_HOUSE_COUNTS;
|
||||
const sourceSha = (() => {
|
||||
try {
|
||||
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
||||
@@ -101,7 +115,7 @@ try {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -164,6 +178,11 @@ try {
|
||||
cleanFloor: card._cleanFloorCache?.size ?? 0,
|
||||
glowClip: card._glowClipCache?.size ?? 0,
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
// #744: the union is pooled per floor, and the inner contours are the
|
||||
// second structural cache of a floor visit; 0/1 above cannot see a
|
||||
// cold floor whose union is rebuilt while its clean floor stays warm.
|
||||
wallUnionPool: card._wallUnionPool?.size ?? 0,
|
||||
innerContour: card._innerContourCache?.size ?? 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
@@ -255,7 +274,8 @@ try {
|
||||
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
|
||||
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
|
||||
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.nudgedOverlayCount !== 0)
|
||||
failures.push('raised overlay nudged although #713 lifts every tile by one rise');
|
||||
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
|
||||
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
|
||||
for (const kind of expectedKinds) {
|
||||
@@ -405,6 +425,10 @@ try {
|
||||
&& card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) {
|
||||
throw new Error(`${profile} did not render the bounded maximum stair collection`);
|
||||
}
|
||||
// #743 precondition (docs/TESTING.md rule 3): the fixture carries the
|
||||
// layer the profile guards, so a stable frame without the backdrop fails.
|
||||
if (backdrop && !card.renderRoot.querySelector('.stage svg image.hp-backdrop'))
|
||||
throw new Error(`${profile} rendered no backdrop image`);
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const bootDiag = {
|
||||
updates: card.__diag.updates,
|
||||
@@ -714,7 +738,7 @@ try {
|
||||
const currentView = card._viewOr(card._baseVb());
|
||||
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
|
||||
const position = currentDevice ? card._pos(currentDevice) : null;
|
||||
const scene = position ? card._scenePoint([position.x, position.y]) : null;
|
||||
const scene = position ? [position.x, position.y] : null;
|
||||
const deviceRect = device.getBoundingClientRect();
|
||||
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
|
||||
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
|
||||
@@ -1120,6 +1144,24 @@ try {
|
||||
card._settingsDialog = null;
|
||||
await card.updateComplete;
|
||||
|
||||
// #735: every sample mounts a new card, and until here it has visited
|
||||
// only floors 1 and 2. The cycle would otherwise pay a first visit to
|
||||
// floor 3 (and, after the interaction editor series moves the config
|
||||
// epoch, to floor 1 again) inside its window, so one cold floor build
|
||||
// dominated a metric documented as warmed navigation. Visit every
|
||||
// fixture floor once in cycle order outside every timed and Long Task
|
||||
// window, then return to floor 2 so the cycle still starts with 2 -> 1.
|
||||
for (let floor = 1; floor <= fixture.counts.floors; floor++) {
|
||||
card._pickSpace(`perf-floor-${floor}`);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
card._pickSpace('perf-floor-2');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
const switchCycleCachesBefore = cacheSnapshot(card);
|
||||
const switchCycleBuildsBefore = isoStructuralBuildCount(card);
|
||||
const switchCycle = await duration(async () => {
|
||||
for (let index = 0; index < 12; index++) {
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
@@ -1130,6 +1172,37 @@ try {
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
}
|
||||
});
|
||||
// #735 guard: a warmed cycle builds nothing. Any grown hot cache or a
|
||||
// structural 2.5D build inside the window means a cold floor visit
|
||||
// leaked back into switchCycleMs. Caches an older base lacks read as 0
|
||||
// and cannot grow; its build counter is null and is not judged.
|
||||
const switchCycleCachesAfter = cacheSnapshot(card);
|
||||
const switchCycleBuildsAfter = isoStructuralBuildCount(card);
|
||||
const switchCycleBuilt = Object.keys(switchCycleCachesAfter)
|
||||
.filter((key) => switchCycleCachesAfter[key] > switchCycleCachesBefore[key])
|
||||
.map((key) => `${key} +${switchCycleCachesAfter[key] - switchCycleCachesBefore[key]}`);
|
||||
if (switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore)
|
||||
switchCycleBuilt.push(`isoStructuralBuilds +${switchCycleBuildsAfter - switchCycleBuildsBefore}`);
|
||||
if (switchCycleBuilt.length)
|
||||
throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleBuilt.join(', ')}`);
|
||||
|
||||
// #743 structural probe, untimed and outside every Long Task window: six
|
||||
// warm switches round the floors (the cycle ended on the last floor, so
|
||||
// each one is a real switch). Each counts the update passes from the
|
||||
// pick until updateComplete reports quiescence (resolves true). Counting
|
||||
// right after the first await would miss the #739 K1 second pass: it
|
||||
// starts after that promise resolves, in the same task. The wait is
|
||||
// bounded so an endless update loop yields a large count instead of a
|
||||
// hung job. evaluate.mjs judges the candidate; a base is only reported.
|
||||
const floorSwitchPasses = [];
|
||||
if (backdrop) {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
const passesBefore = card.__diag.updates;
|
||||
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
|
||||
for (let wait = 0; wait < 20 && !(await card.updateComplete); wait++);
|
||||
floorSwitchPasses.push(card.__diag.updates - passesBefore);
|
||||
}
|
||||
}
|
||||
|
||||
await forceGc();
|
||||
const cacheBefore = cacheSnapshot(card);
|
||||
@@ -1207,6 +1280,7 @@ try {
|
||||
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
|
||||
},
|
||||
} : {}),
|
||||
...(backdrop ? { floorSwitchPasses: { supported: true, perSwitch: floorSwitchPasses } } : {}),
|
||||
...(finalStage3 ? {
|
||||
isoStageRevision: finalStage3.isoStageRevision,
|
||||
stage3Diagnostics: {
|
||||
@@ -1224,7 +1298,7 @@ try {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
|
||||
});
|
||||
// #520: диагностика печатается в лог прогона и в запись не попадает.
|
||||
const { bootDiag, ...measured } = row;
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* #780 ТЗ §13.2: the `led-strips-v1` performance profile.
|
||||
*
|
||||
* The `large-house-v1` fixture (200 devices, 3 floors, 60 rooms, 100
|
||||
* openings) with 10 or 50 of each floor's existing devices shown as LED
|
||||
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
|
||||
* (50 strips × 50 points); `--size=none` is the same build and plan without
|
||||
* strips. No icon is added: the converted devices keep their ids, only their
|
||||
* entity becomes a light. Light radius 30 cm (the strip default), Glow on,
|
||||
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
|
||||
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
|
||||
* browser with an empty cache mounts a new card; the warm metrics run in the
|
||||
* same page after the module has loaded.
|
||||
*
|
||||
* Metrics (judged on median AND p95 against budgets-led-strips.json):
|
||||
* firstStableRenderMs (mount → stripes and fields of every visible strip,
|
||||
* cold import included), warmSpaceReadyMs (space switch with the module
|
||||
* loaded → stable LED frame), stateUpdateMs (the interaction profile's method:
|
||||
* hass update → every stripe in its new state), panZoomMs (the interaction
|
||||
* profile's camera scenario, benchmark_large_house.mjs) and the longest Long
|
||||
* Task of the camera — the scenario and the 100-step series, retained heap
|
||||
* after 20 A→B→C→A cycles over a warm cycle (same GC protocol).
|
||||
*
|
||||
* Counters (exact, every sample): geometry/visibility recomputes over 100
|
||||
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
|
||||
* three LED caches of the shown space — shapes ≤ 50, visibility ≤ 50,
|
||||
* compact visibility paths ≤ 2500 and retained path text ≤ 4 Mi characters
|
||||
* (≤ 8 MiB UTF-16) — identical after every cycle (growth 0). The true fan
|
||||
* count remains diagnostic: #788 removed lossy endpoint/vertex thinning;
|
||||
* after disconnect 0 retained entries and 0 live LED timers/observers; a
|
||||
* late import after disconnect restores nothing (one extra cold run with the
|
||||
* runtime response delayed); LED chunk requests (none without strips, never
|
||||
* the editor chunk in the View).
|
||||
*
|
||||
* `--merge=a.json,b.json` judges the rows of several partial reports of the
|
||||
* same size as one run — the samples are the same cold samples, only the
|
||||
* process that collected them differs (a runner with a short step limit);
|
||||
* `--warmup-only` collects the discarded warm-up of such a run.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
|
||||
import { ledChunkRequestName } from './performance/led-chunk-request.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const warmupOnly = process.argv.includes('--warmup-only');
|
||||
const samples = warmupOnly ? 0 : Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
const warmups = warmupOnly ? 1 : Math.max(0, Math.min(5, Number(valueArg('warmups') ?? 1)));
|
||||
const size = valueArg('size') ?? '10x5';
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const merge = valueArg('merge')?.split(',').filter(Boolean).map((file) => resolve(file)) ?? null;
|
||||
const skipLateImport = process.argv.includes('--no-late-import');
|
||||
const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] };
|
||||
if (!SIZES[size]) throw new Error(`unknown size: ${size}`);
|
||||
const [STRIPS, POINTS] = SIZES[size];
|
||||
const PROFILE = 'led-strips-v1';
|
||||
const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20));
|
||||
const MIN_SAMPLES = 7;
|
||||
|
||||
const fixture = makeLedStripsFixture(STRIPS, POINTS);
|
||||
const sourceSha = (() => {
|
||||
try { return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { return null; }
|
||||
})();
|
||||
const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8'));
|
||||
|
||||
/**
|
||||
* Installed in the page before the card mounts anything: timers, frames and
|
||||
* observers created from an LED chunk (its file name is in the creation
|
||||
* stack) are tracked until they fire, are cleared or disconnected.
|
||||
*/
|
||||
function installLedLifecycleTracker() {
|
||||
if (window.__ledLive) return;
|
||||
const live = { timers: new Set(), frames: new Set(), observers: new Set() };
|
||||
window.__ledLive = live;
|
||||
const fromLed = () => /led-strip-/.test(new Error().stack || '');
|
||||
const nativeTimeout = window.setTimeout, nativeClearTimeout = window.clearTimeout;
|
||||
window.setTimeout = function setTimeout(fn, ms, ...rest) {
|
||||
const mine = fromLed();
|
||||
let id = 0;
|
||||
id = nativeTimeout.call(window, typeof fn === 'function'
|
||||
? (...args) => { live.timers.delete(id); return fn(...args); } : fn, ms, ...rest);
|
||||
if (mine) live.timers.add(id);
|
||||
return id;
|
||||
};
|
||||
window.clearTimeout = function clearTimeout(id) { live.timers.delete(id); return nativeClearTimeout.call(window, id); };
|
||||
const nativeInterval = window.setInterval, nativeClearInterval = window.clearInterval;
|
||||
window.setInterval = function setInterval(fn, ms, ...rest) {
|
||||
const id = nativeInterval.call(window, fn, ms, ...rest);
|
||||
if (fromLed()) live.timers.add(id);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function clearInterval(id) { live.timers.delete(id); return nativeClearInterval.call(window, id); };
|
||||
const nativeFrame = window.requestAnimationFrame, nativeCancelFrame = window.cancelAnimationFrame;
|
||||
window.requestAnimationFrame = function requestAnimationFrame(fn) {
|
||||
const mine = fromLed();
|
||||
let id = 0;
|
||||
id = nativeFrame.call(window, (time) => { live.frames.delete(id); return fn(time); });
|
||||
if (mine) live.frames.add(id);
|
||||
return id;
|
||||
};
|
||||
window.cancelAnimationFrame = function cancelAnimationFrame(id) { live.frames.delete(id); return nativeCancelFrame.call(window, id); };
|
||||
for (const name of ['ResizeObserver', 'MutationObserver', 'IntersectionObserver']) {
|
||||
const Native = window[name];
|
||||
if (!Native) continue;
|
||||
window[name] = class extends Native {
|
||||
constructor(callback) {
|
||||
super(callback);
|
||||
if (fromLed()) live.observers.add(this);
|
||||
}
|
||||
disconnect() { live.observers.delete(this); return super.disconnect(); }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The page side of one mount: the same fixture host as before, shared by the sample and the late-import run. */
|
||||
function pageHost() {
|
||||
return `
|
||||
window.__ledHost = (fixture) => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
|
||||
let states = fixture.states;
|
||||
const hassBase = {
|
||||
language: 'en', locale: { language: 'en' },
|
||||
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
|
||||
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: true };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: structuredClone(fixture.layout), rev: 1 };
|
||||
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
|
||||
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
|
||||
return { ok: true };
|
||||
},
|
||||
callService: async () => undefined,
|
||||
connection: { subscribeEvents: async () => () => undefined, subscribeMessage: async () => () => undefined },
|
||||
localize: () => null,
|
||||
formatEntityState: (state) => state.state,
|
||||
config: { unit_system: { length: 'km' } },
|
||||
};
|
||||
return {
|
||||
card,
|
||||
get states() { return states; },
|
||||
setStates(next) { states = next; card.hass = { ...hassBase, states }; },
|
||||
};
|
||||
};
|
||||
window.__ledStats = async (card) => {
|
||||
const url = performance.getEntriesByType('resource').map((entry) => entry.name)
|
||||
.find((name) => /led-strip-runtime-[^/]+\\.js/.test(name));
|
||||
if (!url) return { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, recomputes: 0, loaded: false };
|
||||
const runtime = await import(url);
|
||||
return { ...runtime.ledStats(card), loaded: true };
|
||||
};
|
||||
window.__ledLiveCounts = () => ({
|
||||
timers: window.__ledLive.timers.size, frames: window.__ledLive.frames.size,
|
||||
observers: window.__ledLive.observers.size,
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
async function sample() {
|
||||
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
|
||||
const chromium = await browser.version();
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const requests = [];
|
||||
page.on('request', (request) => {
|
||||
const name = ledChunkRequestName(request.url());
|
||||
if (name) requests.push(name);
|
||||
});
|
||||
try {
|
||||
await page.evaluate(installLedLifecycleTracker);
|
||||
await page.evaluate(pageHost());
|
||||
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const until = async (predicate, timeout = 30000) => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
|
||||
await sleep(5);
|
||||
}
|
||||
};
|
||||
const longTasks = () => {
|
||||
const entries = [];
|
||||
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
|
||||
observer.observe({ type: 'longtask', buffered: false });
|
||||
return async () => {
|
||||
await sleep(0);
|
||||
entries.push(...observer.takeRecords());
|
||||
observer.disconnect();
|
||||
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
|
||||
};
|
||||
};
|
||||
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = window.__ledHost(fixture);
|
||||
const { card } = host;
|
||||
const root = () => card.renderRoot;
|
||||
const count = (selector) => root()?.querySelectorAll(selector).length ?? 0;
|
||||
const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips
|
||||
&& (state !== 'on' || count('.led-fields [data-led-field]') === strips);
|
||||
const stats = () => window.__ledStats(card);
|
||||
|
||||
const started = performance.now();
|
||||
document.getElementById('host').replaceChildren(card);
|
||||
host.setStates(host.states);
|
||||
await until(() => card._loadOk && card._model?.length === 3);
|
||||
if (strips) await until(() => ledStable());
|
||||
else { await until(() => card._booting === false); await frame(); }
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - started).toFixed(2));
|
||||
|
||||
const warm = performance.now();
|
||||
card._pickSpace('perf-floor-2');
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable());
|
||||
await frame();
|
||||
const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2));
|
||||
|
||||
const states = host.states;
|
||||
const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_'));
|
||||
const stateStarted = performance.now();
|
||||
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable('off'));
|
||||
await frame();
|
||||
const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2));
|
||||
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable());
|
||||
await frame();
|
||||
|
||||
const before = await stats();
|
||||
// 100 unrelated HA ticks.
|
||||
const sensor = Object.keys(host.states).find((id) => id.startsWith('sensor.'));
|
||||
for (let tick = 0; tick < 100; tick++) {
|
||||
host.setStates({ ...host.states, [sensor]: { ...host.states[sensor], state: String(20 + (tick % 10) / 10) } });
|
||||
await card.updateComplete;
|
||||
}
|
||||
await frame();
|
||||
const afterTicks = await stats();
|
||||
// The camera: the interaction profile's scenario (one wheel step, its
|
||||
// render and frame — benchmark_large_house.mjs), then 100 steps whose
|
||||
// Long Tasks are judged too and whose recomputes are counted.
|
||||
const stage = root().querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
|
||||
deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true,
|
||||
}));
|
||||
const stopPan = longTasks();
|
||||
const panStarted = performance.now();
|
||||
wheel(-120);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const panZoomMs = Number((performance.now() - panStarted).toFixed(2));
|
||||
const panZoomLongTaskMaxMs = await stopPan();
|
||||
const stopSeries = longTasks();
|
||||
// One wheel step per frame, as a real wheel or trackpad delivers them.
|
||||
for (let step = 0; step < 100; step++) {
|
||||
wheel(step % 2 ? 60 : -60);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
await frame();
|
||||
const cameraSeriesLongTaskMaxMs = await stopSeries();
|
||||
const afterCamera = await stats();
|
||||
// A colour-only change rebuilds no geometry or visibility.
|
||||
host.setStates({ ...host.states, ...Object.fromEntries(floorLights.map((id) => [id,
|
||||
{ ...host.states[id], attributes: { ...host.states[id].attributes, rgb_color: [255, 120, 80] } }])) });
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const afterColour = await stats();
|
||||
|
||||
// 20 cycles A→B→C→A after one warm cycle; the three caches identical after each.
|
||||
const cycle = async () => {
|
||||
for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) {
|
||||
card._pickSpace(id);
|
||||
await card.updateComplete;
|
||||
if (strips) await until(() => ledStable());
|
||||
}
|
||||
await frame();
|
||||
const { shapes, visibility, sources, visibilityPaths, pathChars } = await stats();
|
||||
return { shapes, visibility, sources, visibilityPaths, pathChars };
|
||||
};
|
||||
await cycle();
|
||||
await gc();
|
||||
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
|
||||
const cycleStats = [];
|
||||
for (let index = 0; index < cycles; index++) cycleStats.push(await cycle());
|
||||
await gc();
|
||||
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
|
||||
const liveBeforeDisconnect = window.__ledLiveCounts();
|
||||
card.remove();
|
||||
await frame();
|
||||
await sleep(600);
|
||||
const afterDisconnect = await stats();
|
||||
const liveAfterDisconnect = window.__ledLiveCounts();
|
||||
const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}/${entry.visibilityPaths}/${entry.pathChars}`;
|
||||
return {
|
||||
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
|
||||
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
|
||||
counters: strips ? {
|
||||
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
|
||||
recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes,
|
||||
recomputesOnColour: afterColour.recomputes - afterCamera.recomputes,
|
||||
shapes: Math.max(...cycleStats.map((entry) => entry.shapes)),
|
||||
visibility: Math.max(...cycleStats.map((entry) => entry.visibility)),
|
||||
sources: Math.max(...cycleStats.map((entry) => entry.sources)),
|
||||
visibilityPaths: Math.max(...cycleStats.map((entry) => entry.visibilityPaths)),
|
||||
pathChars: Math.max(...cycleStats.map((entry) => entry.pathChars)),
|
||||
cacheGrowthOverCycles: new Set(cycleStats.map(key)).size - 1,
|
||||
} : null,
|
||||
disconnect: {
|
||||
retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources
|
||||
+ afterDisconnect.visibilityPaths + afterDisconnect.pathChars,
|
||||
liveBefore: liveBeforeDisconnect,
|
||||
live: liveAfterDisconnect.timers + liveAfterDisconnect.frames + liveAfterDisconnect.observers,
|
||||
},
|
||||
};
|
||||
}, { fixture, strips: STRIPS, cycles: CYCLES });
|
||||
row.ledRequests = [...new Set(requests)].sort();
|
||||
return { row, chromium };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The late import: the card is removed while the runtime chunk is still on
|
||||
* the wire (its response is held back); when it lands nothing is rendered,
|
||||
* cached or scheduled for the gone card.
|
||||
*/
|
||||
async function lateImport() {
|
||||
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1, []);
|
||||
try {
|
||||
let held = false;
|
||||
let runtimeUrl = null;
|
||||
await page.route(/led-strip-runtime-[^/]+\.js/, async (route) => {
|
||||
held = true;
|
||||
runtimeUrl = route.request().url();
|
||||
await new Promise((done) => setTimeout(done, 1500));
|
||||
await route.fallback();
|
||||
});
|
||||
await page.evaluate(installLedLifecycleTracker);
|
||||
await page.evaluate(pageHost());
|
||||
const result = await page.evaluate(async ({ fixture }) => {
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
window.__card?.remove?.();
|
||||
const host = window.__ledHost(fixture);
|
||||
const { card } = host;
|
||||
document.getElementById('host').replaceChildren(card);
|
||||
host.setStates(host.states);
|
||||
const started = performance.now();
|
||||
while (!(card._loadOk && card._model?.length === 3) && performance.now() - started < 20000) await sleep(10);
|
||||
await sleep(300);
|
||||
const before = card.renderRoot.querySelectorAll('[data-led-strip]').length;
|
||||
card.remove();
|
||||
window.__lateCard = card;
|
||||
await sleep(3000);
|
||||
return { stripesBeforeLoad: before, stripesAfterLoad: card.renderRoot.querySelectorAll('[data-led-strip]').length };
|
||||
}, { fixture });
|
||||
// The chunk the card asked for, by its URL: the same module instance.
|
||||
const late = await page.evaluate(async (url) => {
|
||||
const runtime = url ? await import(url) : null;
|
||||
return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, visibilityPaths: 0, pathChars: 0, loaded: false },
|
||||
live: window.__ledLiveCounts() };
|
||||
}, runtimeUrl);
|
||||
return { ...result, ...late, held };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
let chromium = null;
|
||||
let late = null;
|
||||
let discarded = 0;
|
||||
if (merge) {
|
||||
for (const file of merge) {
|
||||
const part = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (part.profile !== PROFILE || part.size !== size) throw new Error(`${file} is not ${PROFILE} ${size}`);
|
||||
rows.push(...part.rows);
|
||||
discarded += part.warmups;
|
||||
chromium ??= part.chromium;
|
||||
late ??= part.lateImport ?? null;
|
||||
}
|
||||
} else {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const result = await sample();
|
||||
chromium ??= result.chromium;
|
||||
if (iteration >= warmups) rows.push(result.row);
|
||||
}
|
||||
discarded = warmups;
|
||||
if (STRIPS && !skipLateImport && !warmupOnly) late = await lateImport();
|
||||
}
|
||||
if (warmupOnly) {
|
||||
// A discarded warm-up sample for a later --merge: nothing is judged here.
|
||||
const text = `${JSON.stringify({ profile: PROFILE, size, sourceSha, chromium, samples: 0, warmups: 1, rows: [], partial: true }, null, 2)}\n`;
|
||||
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
|
||||
console.log(text);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const metric = (name) => {
|
||||
const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)];
|
||||
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
|
||||
};
|
||||
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs',
|
||||
'cameraSeriesLongTaskMaxMs', 'retainedHeapBytes'];
|
||||
const report = {
|
||||
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
|
||||
samples: rows.length, warmups: discarded, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
|
||||
merged: merge ? merge.length : undefined,
|
||||
metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])),
|
||||
counters: rows[0]?.counters ?? null,
|
||||
counterSamples: rows.map((row) => row.counters),
|
||||
disconnectSamples: rows.map((row) => row.disconnect),
|
||||
lateImport: late,
|
||||
ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(),
|
||||
rows,
|
||||
};
|
||||
const failures = [];
|
||||
const partial = !merge && (samples < MIN_SAMPLES || warmups < 1);
|
||||
if (!partial && (report.samples < MIN_SAMPLES || report.warmups < 1)) {
|
||||
failures.push(`${report.samples} samples after ${report.warmups} warmups: the profile needs ≥ ${MIN_SAMPLES} after ≥ 1`);
|
||||
}
|
||||
const limits = budgets.sizes[size] || {};
|
||||
for (const [name, limit] of Object.entries(limits)) {
|
||||
for (const stat of ['median', 'p95']) {
|
||||
const value = report.metrics[name]?.[stat];
|
||||
if (value == null) { if (name !== 'retainedHeapBytes') failures.push(`${name} ${stat} missing`); continue; }
|
||||
if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`);
|
||||
}
|
||||
}
|
||||
for (const [index, disconnect] of report.disconnectSamples.entries()) {
|
||||
if (disconnect.retained !== 0) failures.push(`sample ${index}: ${disconnect.retained} LED cache entries retained after disconnect`);
|
||||
if (disconnect.live !== 0) failures.push(`sample ${index}: ${disconnect.live} LED timers/frames/observers alive after disconnect`);
|
||||
}
|
||||
if (STRIPS) {
|
||||
const caches = budgets.caches;
|
||||
for (const counters of report.counterSamples) {
|
||||
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
|
||||
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
|
||||
}
|
||||
for (const key of ['shapes', 'visibility', 'visibilityPaths', 'pathChars']) {
|
||||
if (!Number.isFinite(counters[key])) failures.push(`${key} cache metric missing`);
|
||||
else if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
|
||||
}
|
||||
}
|
||||
if (late) {
|
||||
if (!late.held || !late.stats.loaded) failures.push('late import: the runtime response was never held or never landed');
|
||||
if (late.stripesAfterLoad !== late.stripesBeforeLoad) failures.push('late import rendered the gone card');
|
||||
const retained = late.stats.shapes + late.stats.visibility + late.stats.sources
|
||||
+ late.stats.visibilityPaths + late.stats.pathChars;
|
||||
if (retained) failures.push(`late import restored ${retained} cache entries`);
|
||||
if (late.live.timers + late.live.frames + late.live.observers) failures.push('late import left LED timers/observers');
|
||||
} else if (!skipLateImport) failures.push('late import not measured');
|
||||
if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field'))
|
||||
failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`);
|
||||
} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`);
|
||||
if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk');
|
||||
report.failures = failures;
|
||||
report.partial = partial || undefined;
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
|
||||
console.log(JSON.stringify({ ...report, rows: undefined }, null, 2));
|
||||
if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); }
|
||||
@@ -136,7 +136,53 @@ const applianceSpace = {
|
||||
decor: [],
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false) => {
|
||||
// #780: the LED strip reference scene — the designer's four strips on a
|
||||
// synthetic plan with the reference floor colour #868D94: a free straight
|
||||
// strip, a strip on the inner face of the bottom wall, a polyline along the
|
||||
// wall faces around a corner and a closed rectangle. The colours are scene
|
||||
// values of the four light sources, not a product palette.
|
||||
const ledRooms = [
|
||||
{ id: 'led-living', name: 'LED living', area: 'golden_led_living',
|
||||
poly: [[0.06, 0.08], [0.56, 0.08], [0.56, 0.62], [0.06, 0.62]] },
|
||||
{ id: 'led-studio', name: 'LED studio', area: 'golden_led_studio',
|
||||
poly: [[0.56, 0.08], [0.94, 0.08], [0.94, 0.62], [0.56, 0.62]] },
|
||||
];
|
||||
// Inner faces of the 20 cm walls (half thickness = 10 cm = 2 cells of 1/240).
|
||||
const LED_FACE = 2 / 240;
|
||||
export const LED_REFERENCE_STRIPS = Object.freeze([
|
||||
{ id: 'led-free', marker: 'demo-led-strip', color: [128, 213, 255],
|
||||
points: [[0.14, 0.26], [0.44, 0.26]] },
|
||||
{ id: 'led-wall', marker: 'demo-led-strip-wall', color: [230, 128, 255],
|
||||
points: [[0.12, round(0.62 - LED_FACE)], [0.50, round(0.62 - LED_FACE)]] },
|
||||
{ id: 'led-corner', marker: 'demo-led-strip-corner', color: [255, 234, 128],
|
||||
points: [[0.64, round(0.08 + LED_FACE)], [round(0.94 - LED_FACE), round(0.08 + LED_FACE)],
|
||||
[round(0.94 - LED_FACE), 0.42]] },
|
||||
{ id: 'led-loop', marker: 'demo-led-strip-loop', color: [88, 255, 88],
|
||||
points: [[0.66, 0.30], [0.84, 0.30], [0.84, 0.52], [0.66, 0.52], [0.66, 0.30]] },
|
||||
]);
|
||||
|
||||
const ledSpace = {
|
||||
id: 'golden-led',
|
||||
title: 'LED strips',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 0.7],
|
||||
cell_cm: 5,
|
||||
settings: {
|
||||
fill_mode: 'custom', custom_fill: { c: '#868D94', a: 1 }, glow_enabled: true,
|
||||
show_borders: true, show_names: false, sun_rays: false, bg_mode: 'static',
|
||||
},
|
||||
rooms: ledRooms,
|
||||
walls: wallsFor('led', ledRooms, 20),
|
||||
openings: [
|
||||
{ id: 'led-door', type: 'door', x: 0.56, y: 0.53, angle: 90, length: 0.10 },
|
||||
],
|
||||
partitions: [],
|
||||
wall_columns: [],
|
||||
decor: [],
|
||||
led_strips: LED_REFERENCE_STRIPS.map(({ id, marker, points }) => ({ id, marker, points })),
|
||||
};
|
||||
|
||||
const runtime = (includeAppliance = false, includeLed = false) => {
|
||||
const devices = {};
|
||||
const entities = {};
|
||||
const states = {
|
||||
@@ -199,6 +245,15 @@ const runtime = (includeAppliance = false) => {
|
||||
unit_of_measurement: 'lqi',
|
||||
});
|
||||
|
||||
if (includeLed) {
|
||||
for (const room of ledRooms) areas[room.area] = { area_id: room.area, name: room.name };
|
||||
for (const strip of LED_REFERENCE_STRIPS) {
|
||||
add(strip.marker, 'light', ledRooms[strip.id === 'led-free' || strip.id === 'led-wall' ? 0 : 1].area,
|
||||
0.5, 0.5, 'on', { rgb_color: strip.color, brightness: 255 });
|
||||
layout[strip.marker] = { s: 'golden-led', x: 0.5, y: 0.35 };
|
||||
}
|
||||
}
|
||||
|
||||
if (includeAppliance) {
|
||||
const washerId = 'golden-washer';
|
||||
devices[washerId] = {
|
||||
@@ -247,16 +302,23 @@ export const VISUAL_MATRIX_COUNTS = Object.freeze({
|
||||
columns: geometrySpace.wall_columns.length + lightingSpace.wall_columns.length,
|
||||
});
|
||||
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) => ({
|
||||
export const makeVisualMatrixFixture = ({ applianceLifecycle = false, ledStrips = false } = {}) => ({
|
||||
config: {
|
||||
spaces: [
|
||||
structuredClone(geometrySpace), structuredClone(lightingSpace),
|
||||
...(applianceLifecycle ? [structuredClone(applianceSpace)] : []),
|
||||
...(ledStrips ? [structuredClone(ledSpace)] : []),
|
||||
],
|
||||
// A persisted marker is part of the fixture contract for scenarios that
|
||||
// override per-source Glow controls. The device/layout alone are not a
|
||||
// saved marker configuration and must not be silently treated as one.
|
||||
markers: [{ id: 'golden-light-two', binding: 'device:golden-light-two' }],
|
||||
markers: [
|
||||
{ id: 'golden-light-two', binding: 'device:golden-light-two' },
|
||||
// #780: a strip link writes the marker's explicit space (ТЗ §5).
|
||||
...(ledStrips ? LED_REFERENCE_STRIPS.map(({ marker }) => ({
|
||||
id: marker, binding: `device:${marker}`, space: 'golden-led',
|
||||
})) : []),
|
||||
],
|
||||
settings: {
|
||||
glow_radius_cm: 360,
|
||||
north_deg: 0,
|
||||
@@ -269,7 +331,7 @@ export const makeVisualMatrixFixture = ({ applianceLifecycle = false } = {}) =>
|
||||
},
|
||||
},
|
||||
},
|
||||
...runtime(applianceLifecycle),
|
||||
...runtime(applianceLifecycle, ledStrips),
|
||||
counts: applianceLifecycle ? {
|
||||
...VISUAL_MATRIX_COUNTS,
|
||||
spaces: VISUAL_MATRIX_COUNTS.spaces + 1,
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"matrixVersion": 67,
|
||||
"acceptedAt": "2026-09-28T13:36:48.059Z",
|
||||
"sourceFingerprint": "baa0806921c7f72959047c7d2bda343ef5e378c4e8d2a8e612f74b6af2e2c5b1",
|
||||
"matrixVersion": 71,
|
||||
"acceptedAt": "2026-10-03T15:57:17.589Z",
|
||||
"sourceFingerprint": "6cd33bbad3c9a6a101afc1d501a8959df6ad58b693802489873660c043677fa7",
|
||||
"chromium": "151.0.7922.34",
|
||||
"capturedOn": "linux",
|
||||
"acceptedOn": "linux",
|
||||
"acceptedOn": "win32",
|
||||
"capture": {
|
||||
"platform": "linux",
|
||||
"arch": "x64",
|
||||
"chromium": "151.0.7922.34",
|
||||
"buildFingerprint": "baa0806921c7f72959047c7d2bda343ef5e378c4e8d2a8e612f74b6af2e2c5b1",
|
||||
"buildFingerprint": "6cd33bbad3c9a6a101afc1d501a8959df6ad58b693802489873660c043677fa7",
|
||||
"ci": {
|
||||
"repository": "Matysh/houseplan-card",
|
||||
"run": 36427848424,
|
||||
"run": 37134548622,
|
||||
"attempt": 1,
|
||||
"sha": "c1289886175e165c5be09e4bd700a572529da234"
|
||||
"sha": "c142e4f7b5b3ffb46d3a2b88222a9d697e8144b1"
|
||||
}
|
||||
},
|
||||
"localAttestation": null,
|
||||
"foreignCapture": null,
|
||||
"foreignCapture": {
|
||||
"reason": "Owner-authorized #788 acceptance on Windows of Linux CI artifact Validate 37134548622; capture is not Windows (#455)"
|
||||
},
|
||||
"witnesses": {
|
||||
"count": 107,
|
||||
"count": 101,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"isometric-stage6-light-lightfloor": "80ac5d4ed1535fbaa1420b5da1a35599cc8ef9a35e4fa723c89e25ef235f792e",
|
||||
"isometric-stage6-light-darkfloor": "46e25d32a4e4f156586a327cdfbc6f59e79178733b6301c89843b8a8535a738a",
|
||||
"isometric-stage6-dark-darkfloor": "5b76746846c13c2dbca9ade31d4e6cb891a9a486029ebcf4008539a4a92b47fc",
|
||||
"isometric-stage6-dark-lightfloor": "157e0aaf68396b8ed5acc0d2caed935dc0d17c014c265cb2aaeda66944bba8c2",
|
||||
"isometric-stage6-hover-light": "3f8dc5052e883a30e3cf89c1a0fdea0af3bae3c763b6ebaa50e41cba9cdb3bfd",
|
||||
"isometric-stage6-light-lightfloor": "edeadb080b4e9f1114ee30189a37c06bfbf6b1a4657201728fe1dbfc5a8cbf55",
|
||||
"isometric-stage6-light-darkfloor": "2763249d1ad93404f41d4bd725eea2c1a3a57ee34930eb5a9fa8c5826d8aeae5",
|
||||
"isometric-stage6-dark-darkfloor": "1ff0cb3a79d61c0a8debb36e4d14c8ff8e9d0698e6920c6b3f6250738d26e32c",
|
||||
"isometric-stage6-dark-lightfloor": "b871df843117371fb30ae195eb2b4bcc2460bb9f205c60884c13341e64d3150e",
|
||||
"isometric-stage6-hover-light": "67bf3b08548a69a8354d2bb432c6ac5b98670a2732ef63f3e710726c9197f6d7",
|
||||
"stairs-flat-normal-light": "abd698576bb726bc70226deb72c12c3b7ebf36dd3007186e5d9444d630075bef",
|
||||
"stairs-flat-hover-dark": "2502c9526ce73017e465bd35a8609afa0dbcac8dfa34ef37cfad8b3f39ebcd52",
|
||||
"stairs-flat-selected-light": "c395ae3969521f6a157ff6e9460971db26287f9a5ca73f6ecbba6e8d35c3424f",
|
||||
"stairs-isometric-dark": "038bb2752cb98b652541fdc6307f89e85ddd46212014e1991f7086cb42911ab3",
|
||||
"stairs-isometric-dark": "7044fa5ab38b1cbc554391360db4e0906969fba8db238751c8ff6ea113fcf54c",
|
||||
"panel-wide-view-light-en": "275d09b3730361bb491a832fbb76723d90a7327086f042cbf0fdd79f4a002800",
|
||||
"panel-wide-plan-editor-dark-en": "093a3fd1022755f5128261796e009383d7e7a23e9e50f193ab1987d30f9313b2",
|
||||
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
|
||||
@@ -51,17 +53,17 @@
|
||||
"split-corner-wall-thin-dark": "0a8da611ecb6061852cc26ee53c00d12433cf8539681bd91dac3adad1d7e9e67",
|
||||
"split-corner-wall-thick-dark": "2efc65ddaa8777ebffe63205e2a961fa7053c91d3cd8c0af59810bef01ecd498",
|
||||
"split-zero-divider-taper-dark": "f7f9ee435da00dcae043c693127561a5e050675ca4d6f6f0d916ac5dbe24a594",
|
||||
"isometric-geometry-view-dark": "877cde0e1037071a23b3356141c1ddd1a74bcd33da5367ada2809ca6c3ee989b",
|
||||
"isometric-geometry-view-light": "50164a84574bb81b860db94fd07cc5173eb676d4371deb90f3d2e16f5d252398",
|
||||
"isometric-live-layers-dark": "1c3a54350b3bb07b4aa34bf853d22f12622616e1c8208f9d64c5e478c7d92bb2",
|
||||
"isometric-no-borders-dark": "406fa7c7597724cba00df6451a1b81d5c306af0259cc9198e1985b6f325d619d",
|
||||
"isometric-touch-kiosk-dark": "e88e95124300b723e6a3b4563961f7d178a2517f41f7b4a2c9fac3534a57c24f",
|
||||
"isometric-large-warm-remount-dark": "d7932b8baea6b5689480a123698d562bcea2eb0d8536c3516272f5ebef4a4cc8",
|
||||
"isometric-stage3-overlays-light": "3824e3c9901a53a19d2a5e069922e982bc2c8bdf21fc5ba6e8e6477bc2bb1f6d",
|
||||
"isometric-stage3-overlays-dark": "adb042cf55b0c0e75412eea9167aec1df03e24cadb72e8e67d259927d687009b",
|
||||
"isometric-stage3-openings-dark": "33c7c657229b44a3a52bbefe0197302d909b69dc0f1127352410d13f6d0d93f7",
|
||||
"isometric-stage3-forced-colors-dark": "06675a2bf71d405c34dc430e1761f5cb78168d5b52207bb2ca7923f6dd8ce50b",
|
||||
"isometric-stage3-no-filter-dark": "bdec8fce7628934e00a29b2c489226c8b0ca33b3e315fdd34c1f89a804dc125e",
|
||||
"isometric-geometry-view-dark": "dd2a2b7bf3f80998ff862fb740d9eba6d92239d15c74d52529fe1bcf25974760",
|
||||
"isometric-geometry-view-light": "72ab80a0d826657266ed1d7f4bcc9ecd482c05bd9d71dbbafad5079d287d5d95",
|
||||
"isometric-live-layers-dark": "07e0eb592e91eef3bd960d2421bf4b1b77ff977296470e4ffed3aac3acb66b69",
|
||||
"isometric-no-borders-dark": "65b24c7d1236adfd18636b23b75a1bc60a1823e3d3ea537259f8d8749cbd9439",
|
||||
"isometric-touch-kiosk-dark": "71192d83e38a8d306f6a24fdd422faf2917f97a9a95484a0b0bdf6932a447601",
|
||||
"isometric-large-warm-remount-dark": "85b212f1bb65b4500e4c24459d1eed333d8ba8c8864fd06d4844e91c39ea707d",
|
||||
"isometric-stage3-overlays-light": "51c87e932f59bde985ec51fc670a81abf1b5ae69c42a0ad9486897bb448eac27",
|
||||
"isometric-stage3-overlays-dark": "7f36dee09f08c4c0373bca28e3cdb19c8f7424885649841149d88fbbc5a7c992",
|
||||
"isometric-stage3-openings-dark": "0a7a6dc33cd47191397f7677da106ec42bd04872b08b7ead9ab76d133aeb1a7d",
|
||||
"isometric-stage3-forced-colors-dark": "8b86a9b7d32259b1bd86bb8626523ac36d4112a5411399c174ed730b760b7fc7",
|
||||
"isometric-stage3-no-filter-dark": "c6617a286c3819fb0e96fcf2e3f5be1c3ff36af762bd88b718631719e31cda86",
|
||||
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
|
||||
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
|
||||
"pdf-export-geometry-light": "c403a141cefb73e4653f2ffb9c346b4cfaa3de339b6c39c227ef1552ebde0048",
|
||||
@@ -78,6 +80,10 @@
|
||||
"day-cycle-day-dark": "605849bbada8d3c3a02a2c88c7b21c81c675a7a89b72c4c560df635d63005e67",
|
||||
"day-cycle-dusk-dark": "d19cc97b75014d9f0661705df50d5f30a3a574661957c04c5dab96e26c5e3364",
|
||||
"day-cycle-night-dark": "39a6a959676295eb4ead02703533641f57901ad0ce1359933e1d47f48867196a",
|
||||
"day-cycle-night-moon-gibbous-dark": "89ba8cdc95316a670486900b2b1aaf7b4c8889b388b54d16e3a645f1e5d41020",
|
||||
"day-cycle-dusk-moon-crescent-south-dark": "03022fc1a57e05b9ec6ace88224d5bded6ee2335bb6b5715089edaf224ce0a4e",
|
||||
"static-bg-moon-gibbous-white-light": "f23cd2ca25a0e18a2d3e6df762e2b0b04f1e89b77bee20fdded895bf9a155112",
|
||||
"static-bg-moon-crescent-south-dark": "bb7d6a2f80741ce77c39a2258c6a28bbd1cc158ff316bb063a2d85ee63d01059",
|
||||
"geometry-plan-editor-dark": "5e7365ef5820a9c2ea51abd3be0c56900de7932e3da4f78fc8103f307a5fcf65",
|
||||
"safe-resize-handles-clamp-light": "80b3cf9c755dbb77deceaf6deb4569bd1570093509b37e96beb32dd2a07d40da",
|
||||
"safe-resize-handles-clamp-dark": "dd32a1034f349b27717a89516b91a85b916e70080cfeffb127b0b51a24683bff",
|
||||
@@ -96,7 +102,7 @@
|
||||
"orthogonal-strip-cell-5-view-dark": "267a3eab1f4ad7d8d484eb2a58b3e38031b0516ca8c46c93e272bfaf9ac409b8",
|
||||
"orthogonal-strip-cell-1-view-dark": "7c9bab0789f9ec9019edf3c49176415af83e8a325e833f98a0e6324e7f06fe4c",
|
||||
"wall-key-roundtrip-view-dark": "74fd46cffefd32d805296d844c162dbe6f15b13e86c4d6c7f6974293b253e1b4",
|
||||
"isometric-wall-junctions-dark": "e6bf156d2afb70a631ceade26d6431caf92721a28a661fffd1654f49711fcb3e",
|
||||
"isometric-wall-junctions-dark": "5159bfe6cd10543e10cf5c98e490ecee877aa6021e0843dd2e2584250540e276",
|
||||
"opening-placement-door-thick-wall-dark": "cef2c75a6c60197c24a848fab7e47919437c751aae79ec4d88d744eb70abed43",
|
||||
"opening-placement-passage-thick-wall-dark": "8d089364f6f9aace8a0cccad952731e6d295440b9eb1c8cb75b6e2264b8fa5c5",
|
||||
"opening-placement-passage-thick-wall-light": "14c06cd119a476f8a4edb63db37d55c6d4000f6fbb3ed412cb88e225574896a6",
|
||||
@@ -107,18 +113,18 @@
|
||||
"static-hatch-openings-140-dpr2-light": "482795eb2186c1cf4b914568a0c3349a864fce6b69c3c05d40a780e75f61e148",
|
||||
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
|
||||
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
|
||||
"isometric-opening-symbol-parity-dark": "5dfcd219d9cdc41c382d6e436f15665c0a265ba2b7138b2856e1a50f0e252062",
|
||||
"geometry-devices-editor-dark": "1cc18ac3250a0cc20981b090ef7f964555b237e085e3ddbe10cc7b6081342c8c",
|
||||
"isometric-opening-symbol-parity-dark": "639e19dddf6d0a8e4f145b6850b0ad5d20d83100fd769e2aad678eb093a33067",
|
||||
"geometry-devices-editor-dark": "0744447d86d071d5ad1c66e44ce3fdde40b6f06b36bdab59c5a2b2cd1da72292",
|
||||
"device-inbox-desktop-en-light": "3a1229f1f5134c329ee457a62e555896ffd664d1d6cbd217e4e8248dc0c07dc7",
|
||||
"device-inbox-desktop-ru-dark": "2b0981e7cc134a8f2cfe107c58833e33b862a00a89cadc5033f3b633562f9379",
|
||||
"device-inbox-narrow-ru-dark": "7694119b368baf49fe8c0aeeec46a75bd77c0987266e4a64f5d13a2099e5f098",
|
||||
"geometry-decor-editor-dark": "0fddb5ba477ecad15827215d04d22e9ba1adfacbbfc853712b1e76832182fa5f",
|
||||
"device-inbox-narrow-ru-dark": "694b84a02e4122b973229488440e19e177b3d77dd8e9eb423304c709ffed6ad5",
|
||||
"geometry-decor-editor-dark": "4077eb3f49182b5075f8cfd3c488cd6affa0fa26f7977ebb1e94cf57baabdc4e",
|
||||
"tray-wide-selection-en": "e544ee6b4cde64aae188bac9be9e52c1232a3de8e98e1f68af42d8c7064f0a38",
|
||||
"tray-wide-tool-ru": "d3c49be18142f280c21062612cf06dc8df6e5a076c53aeb2d62da8e3975c8003",
|
||||
"tray-medium-group-en": "6ba416e0ca3cb2fba4a420703318e62d8651053d76ae1936a37537ab486af4a8",
|
||||
"tray-medium-selection-ru": "d5a82d474f75f438c06a340897a9e009e7df0c95c5ef07edd3952f0fe43a0f40",
|
||||
"tray-narrow-palette-en": "d2147c10ec130bead031db16090b0638188a548163ca8dfbfa86e68e71865051",
|
||||
"furniture-categories-light": "8ffc0d18edc76dad428190f7c041ce9fdf9c5cc71a8329116bc1c7ae040a7fd8",
|
||||
"furniture-categories-light": "4fdc9db78f6199abe86d1f982e9b1d0b71316f4ee0cbb8a1966742b141e0e0be",
|
||||
"furniture-variants-dark": "368f863d070b8f2f02cf657731aab0e7d80bdf5a817193709bb2838d429ac4e1",
|
||||
"furniture-variants-light": "a702b38b8bd42919a5ed8753b87489545eb6d6538726e5cc3cb856a586834974",
|
||||
"furniture-placement-preview-light": "8a78103d7cdd2808496f6d73b8a65859c4e6b57ee53a0c18f4d90580945673ba",
|
||||
@@ -181,25 +187,25 @@
|
||||
"large-house-zoom-040-dark": "84f9e93b5fd25caaf031dc7758553c29459d4dda31e086b7d79e904c9b3d6230",
|
||||
"large-house-zoom-250-dark": "fa901d097023320a599c1c80be9f9c65a0ce6245bb7182bc0f3d90d679668c17",
|
||||
"large-house-warm-remount-dark": "6ed51a1c76cd7c62875eadafc169f7c6fc25604e64edabd9594a3664c886987d",
|
||||
"device-dialog-desktop-en": "f5d660f9244712f016c02c560a4bd301f3729edd4ca1e63b3d2cf0fab9c71802",
|
||||
"device-dialog-desktop-en": "1a3d6c9c77b263a438eb896f24282af6998ec3e478267423b5c2fbebda75c9b5",
|
||||
"device-dialog-mobile-ru": "2486302565fb8dadb7758384e592b059df45f6f8b92195738e5a0bf62758c713",
|
||||
"device-dialog-desktop-de": "972d56b0a0307ad7a061175aa0f6147c3170fc64eadb88bafd038835fd2ce331",
|
||||
"device-dialog-desktop-de": "082969eec2b7fd472cd65a9ae162430a07bc0e72661a3b5eaae689f490369efa",
|
||||
"room-temperature-dialog-desktop-en": "cea714dbe38185ea3d936fdb7cb71a0205feb74687d3ade517aed28407abd4a9",
|
||||
"room-temperature-dialog-mobile-ru": "5ca1c4f18eab3fb4c0667f76b0d0340df94f24e0457baca89234e0ef159f62d6",
|
||||
"room-discard-dialog-mobile-ru": "9c85af0cdbd2842808ef5ac8fdb306f621a726b0ac77c1449722c85399b4cb3f",
|
||||
"room-discard-dialog-mobile-ru": "311cc2da1d85191ca26e72928a86f0846b021be85ed5ba83dd02dfe04397c10a",
|
||||
"german-view-mobile-light": "1c14ef6e97f0f7c616fe61ad72f4dfa7d94c605efc3d8b26931016c8c13a4282",
|
||||
"toggle-entity-dialog-desktop-en": "075a714a4f013c99614c4941b1202d9ae411e6382f72cb0d0a98e0f669b3388b",
|
||||
"toggle-entity-dialog-mobile-ru": "c5bf5077a33c1acfa5f744e0ab1c15ce6c2147bc3622a7e8754d972aeedb555b",
|
||||
"toggle-entity-dialog-desktop-en": "4a8de7f02995b992cb2e8110305280db4899f6fe9bbe967bb43398a5b479a694",
|
||||
"toggle-entity-dialog-mobile-ru": "e6e11b5a4f0181d0115199d5c3eac93fa2db19c73bf743713a0821ce6d9fc5d9",
|
||||
"device-help-popover-light-ru": "7645a419f8214dacc6b97be894643d12b2a0486342e568b0b3ee42d74b8cf878",
|
||||
"decor-color-popover-mobile-ru": "bdf90e0e265d41d032db948a737eeedd6e60cdacc9f85c9356aa809894654960",
|
||||
"decor-color-popover-desktop-en": "46b38745da10d4467b510c5a257102395a797e0d3229e7ce8c89e4bbf3b937e7",
|
||||
"general-color-popover-desktop-en": "b54ef50909c2e7b667d321457b17205d5de38dd39b219eef05bee5ef8fb6bef2",
|
||||
"settings-help-zoom-200-en-light": "9d9cdc0d849afc821efdf4451be40a1d32ecdff06a91b1d517b46c3f45cbcff0",
|
||||
"settings-help-zoom-200-ru-dark": "cb2a9f210b19057e26d42ca8975c3a888fb95de3f6cbd03515d1eccff39de387",
|
||||
"settings-help-zoom-200-en-light": "5539d8df2691eee0b8e90fa1fe086380870eeca5d945a34bb71e65f18bb3c22f",
|
||||
"settings-help-zoom-200-ru-dark": "7be1e0c156ae3a8f71d6b55fc75e5a20e5a4404efee674fa4bebdb82004daa80",
|
||||
"support-desktop-empty-light-en": "55ffd7a63b8e4d65b8cb695a7b166e0c4bb2e719ce925b8d28b8e8595897750d",
|
||||
"support-desktop-preview-dark-en": "326c1b1ecbe71e0bdaed6445137ee4145418317bf5d5faf10faa297707fdb924",
|
||||
"support-phone-validation-light-ru": "acc540f9459c9dc38981ccb70e75a5415f17c849d3840340648448884bd2ab31",
|
||||
"support-phone-success-dark-en": "b2edd4415043d673d22dfe89ee16b9c190e7f3f9537371f01665e8e41444a00e",
|
||||
"support-phone-validation-light-ru": "85baca107a2620c3d36c09ca9d4f72e029cf41661970983560efc4066f2e5688",
|
||||
"support-phone-success-dark-en": "dcf2cb53e904dd3196052f5e0001b8aee712db9c9c0e4f8cd2982645e4bf7dbd",
|
||||
"support-relay-error-light-en": "adf30f96a926a1730c9ad34d18b4234d9b19140f11d56c8fb7508976f366c11d",
|
||||
"support-tablet-preview-dark-ru": "a69ba86bf069d9beba94eedf6cc3b385b026caeee66348e4ef42c1a072b6df6a",
|
||||
"device-ripple-color-popover-mobile-ru": "8fc31025d32d87e5d3d3df896937d182577cd5169d520e7cf5fc267e7d080776",
|
||||
@@ -212,6 +218,14 @@
|
||||
"optimize-orphan-references-dark-en": "6a9ab05787bc789af6181e6f2abca19d3cf92ac166aac661ad8deb7c7a3c88bb",
|
||||
"optimize-orphan-references-light-ru": "332e56edb151a5a820fc307d69ffd4060348978572e48704b0a2c14ba9802720",
|
||||
"card-editor-invalid-default-floor-light-en": "dbf251ece014cfee252e0dcb2d7c00eda66aa4b5bd125e4c83e248d4319d24b3",
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0"
|
||||
"card-editor-invalid-default-floor-dark-ru": "422b74bb391b87e383350247ef1bc1f10ca48c8f6fe418f75bde94e62e3b8bc0",
|
||||
"led-strip-design-reference-off-light": "1a558111dbeb1962b10f47baad20b4689248a347510261152f0d6196906044fd",
|
||||
"led-strip-design-reference-on-light": "16bfb46f2a90c57e95f1307e375e04e5809d41ae677e3b1e1eb56d7e0bc1d8ec",
|
||||
"lighting-led-strip-glow-dark": "254fe1e39c5bb7bbb7e67527f69c767c8bdb6ed959986a8085b18adb152001d6",
|
||||
"led-strip-long-zigzag-glow-light": "cd0847c03b6b397c93fa923dcd06b30d7d4a8f6c6db2f006b9f3e20de0e66e91",
|
||||
"led-strip-endcaps-zoom-light": "57703b53bd9d2eaae1b3142903a8b574e5d12b761b9b7240543ee2463c44c74d",
|
||||
"led-strip-rectangle-door-zoom-light": "549e9e9110cc3b01537d07bcf6ab89896069a49183a27c22904b89c99e35347c",
|
||||
"led-strip-off-light": "cbb4f4794c871d592917653e8b5713d87a6be6dfe470b2161da760c2bb3cebdc",
|
||||
"iso-led-strip-dark": "572728e34f77f911f0ece247c5b7de6653e9c0dfafe04a0ab198b88644081a58"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 380 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 154 KiB |