mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
436
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa570642df | ||
|
|
2a62ad5b95 | ||
|
|
c716bb0f63 | ||
|
|
ac75df7e9a | ||
|
|
e1ae8f4ac7 | ||
|
|
9e4bfb4379 | ||
|
|
d8465b3a09 | ||
|
|
f6c76b7351 | ||
|
|
51854aea53 | ||
|
|
57ce10721f | ||
|
|
7d4d75bdfb | ||
|
|
9e5f211b0a | ||
|
|
1ae0ea1c3b | ||
|
|
db2224c964 | ||
|
|
4b23679d3c | ||
|
|
f2b66ad677 | ||
|
|
836ef7710f | ||
|
|
87195c2b5d | ||
|
|
600330e187 | ||
|
|
c1e05e465b | ||
|
|
0a09712bc5 | ||
|
|
33ae14a99d | ||
|
|
09f8cf4c11 | ||
|
|
53503e0373 | ||
|
|
37fbb9824d | ||
|
|
3f17a1497d | ||
|
|
20f17fee54 | ||
|
|
1751184b12 | ||
|
|
63c24784b6 | ||
|
|
8207a175d6 | ||
|
|
c04d0778ae | ||
|
|
e44fbd6c9e | ||
|
|
c047ca2230 | ||
|
|
cd59b2eb66 | ||
|
|
52b1b2e7c9 | ||
|
|
52fe4d6499 | ||
|
|
005b7c406d | ||
|
|
0a5b34a571 | ||
|
|
c37b5b92bc | ||
|
|
139dceaa4f | ||
|
|
13af1d5e30 | ||
|
|
e8e7ecc9f2 | ||
|
|
1493145ba5 | ||
|
|
b1bbf4b076 | ||
|
|
128af2392a | ||
|
|
560849a1a7 | ||
|
|
cab4b4153a | ||
|
|
a0b471ca1b | ||
|
|
0ff908690a | ||
|
|
e79f3cbfbb | ||
|
|
ffcdb2945f | ||
|
|
67ac9981cf | ||
|
|
c455e7c82d | ||
|
|
03a3dd9916 | ||
|
|
6d8d077340 | ||
|
|
0dac3488fb | ||
|
|
0991c45374 | ||
|
|
65b3cb25f7 | ||
|
|
cca9bc856b | ||
|
|
df46fd1c3e | ||
|
|
d4a672c715 | ||
|
|
c64a1ab7d5 | ||
|
|
d460b31120 | ||
|
|
f880db0338 | ||
|
|
2f86266f60 | ||
|
|
d5f3e17f24 | ||
|
|
dd365cff45 | ||
|
|
20d2863eea | ||
|
|
2c9ff32dc6 | ||
|
|
1a7b1dfceb | ||
|
|
64b544f168 | ||
|
|
5b5b808184 | ||
|
|
cf01489fa9 | ||
|
|
dd3a50426d | ||
|
|
bfcd9f1f15 | ||
|
|
72ce15bfa1 | ||
|
|
fdde46541e | ||
|
|
4bc3e9baa6 | ||
|
|
696f5a789f | ||
|
|
972ff701d1 | ||
|
|
a57ba2e264 | ||
|
|
708ed850f3 | ||
|
|
c8fc8838d8 | ||
|
|
f4fc3bd680 | ||
|
|
030780386c | ||
|
|
5a258f3128 | ||
|
|
f989fbfe3d | ||
|
|
15ef7d9700 | ||
|
|
58211d8f64 | ||
|
|
bd949a810f | ||
|
|
174ececb28 | ||
|
|
614310a1e7 | ||
|
|
9a1d922b24 | ||
|
|
c081f59a15 | ||
|
|
16cf433164 | ||
|
|
e25702c8d6 | ||
|
|
dd905888cb | ||
|
|
ab89187f4f | ||
|
|
69e0345122 | ||
|
|
89e8aecbc1 | ||
|
|
93051edfc2 | ||
|
|
062af8f1d9 | ||
|
|
08b1c4820b | ||
|
|
dd08aed99e | ||
|
|
cc71918126 | ||
|
|
718afca221 | ||
|
|
b9aa2b54b1 | ||
|
|
196be82d94 | ||
|
|
f340157f59 | ||
|
|
7acf5c74ad | ||
|
|
075a2d3fb1 | ||
|
|
7a4032ba26 | ||
|
|
9a7020ce42 | ||
|
|
19edda83e3 | ||
|
|
9fe7f88cb3 | ||
|
|
c392ad3a9c | ||
|
|
f1a87fbad9 | ||
|
|
ac8eac6034 | ||
|
|
bdac4b4fdc | ||
|
|
6fc789e4c8 | ||
|
|
0a066177cf | ||
|
|
85cba3d8b4 | ||
|
|
cbc39cea7b | ||
|
|
d24b0f0eac | ||
|
|
19587a797f | ||
|
|
1ffb1c4eb3 | ||
|
|
b11d60eb4e | ||
|
|
6183119f1e | ||
|
|
7871efb0d6 | ||
|
|
e62dec74cd | ||
|
|
2475403469 | ||
|
|
2aeda2dfa4 | ||
|
|
855bede537 | ||
|
|
fc237f93be | ||
|
|
15207dbacd | ||
|
|
2f857a0f6b | ||
|
|
7e8605981b | ||
|
|
47c245b5ba | ||
|
|
2728a0ee54 | ||
|
|
df2455c530 | ||
|
|
9bc23f9871 | ||
|
|
e45dcad47a | ||
|
|
944adc65a9 | ||
|
|
21555a5991 | ||
|
|
71c8a87e67 | ||
|
|
8b775247a7 | ||
|
|
c9cd6cbf1f | ||
|
|
d36f79f156 | ||
|
|
05dfcd1c8a | ||
|
|
098732512c | ||
|
|
88946f69db | ||
|
|
7464d3e16c | ||
|
|
f2e114d71d | ||
|
|
9755b200e7 | ||
|
|
8852207b18 | ||
|
|
8176f1190f | ||
|
|
d64338224d | ||
|
|
3f8aae838f | ||
|
|
59a60d526d | ||
|
|
ac6f4bfe2e | ||
|
|
93876d7569 | ||
|
|
ea1f19c352 | ||
|
|
bcc5481557 | ||
|
|
ce26a051e6 | ||
|
|
e18a7c3385 | ||
|
|
16ede0c027 | ||
|
|
42e7bc24a8 | ||
|
|
060d9ea547 | ||
|
|
efdc9df0c6 | ||
|
|
ce4f820a03 | ||
|
|
b68aa216a9 | ||
|
|
19d2463dcc | ||
|
|
8aa7b9295e | ||
|
|
cf876c712a | ||
|
|
2d6f1e8be3 | ||
|
|
6903f3320b | ||
|
|
847ebc91a9 | ||
|
|
2fe20a095b | ||
|
|
8eb97bcf46 | ||
|
|
d03a68b88a | ||
|
|
781902b3a8 | ||
|
|
f8a12f26b3 | ||
|
|
2fdafb5bef | ||
|
|
d06cca2c19 | ||
|
|
8ffbdf8a28 | ||
|
|
c93b3a90ef | ||
|
|
72d49040bf | ||
|
|
288b9b41ab | ||
|
|
3bdd3c5163 | ||
|
|
42f437cafe | ||
|
|
942b9ede67 | ||
|
|
e53f9d4f77 | ||
|
|
40b73cc599 | ||
|
|
6cdcb1ea47 | ||
|
|
85119fd9a2 | ||
|
|
f11c2a4c5d | ||
|
|
bae6378006 | ||
|
|
467eaf873a | ||
|
|
698f638969 | ||
|
|
3e48002c92 | ||
|
|
4c64189564 | ||
|
|
9a19e17858 | ||
|
|
dc83a875f4 | ||
|
|
5321c32ddc | ||
|
|
8906675879 | ||
|
|
d3c88f06f9 | ||
|
|
cff83e43ec | ||
|
|
422221fe93 | ||
|
|
27517db8a4 | ||
|
|
1ecf96d596 | ||
|
|
d65b9e39d5 | ||
|
|
563fbe0759 | ||
|
|
b856dd33c8 | ||
|
|
78d4da4abe | ||
|
|
88c4e4e9ae | ||
|
|
4b8a82c08a | ||
|
|
54f197209d | ||
|
|
43fab645b0 | ||
|
|
9287f798b3 | ||
|
|
f5c6d70b7c | ||
|
|
6d3738adef | ||
|
|
6297af6701 | ||
|
|
803c0f0eaa | ||
|
|
e73e56dd94 | ||
|
|
7cc5423efe | ||
|
|
c40266431c | ||
|
|
4ab7ecb4a2 | ||
|
|
c6937f9fc7 | ||
|
|
9d4f68a4ee | ||
|
|
ec2b8c1522 | ||
|
|
8703727951 | ||
|
|
cc04d341d4 | ||
|
|
8501d10988 | ||
|
|
142340ee1c | ||
|
|
0e8e87303e | ||
|
|
106fdc096d | ||
|
|
e1f1c7d908 | ||
|
|
2ed050311b | ||
|
|
e5ab4659f6 | ||
|
|
11a8b623a2 | ||
|
|
43459bcbe0 | ||
|
|
a55f564498 | ||
|
|
4168b1cfce | ||
|
|
8f13325c52 | ||
|
|
180239f44c | ||
|
|
b9fe916662 | ||
|
|
dc0a5ccc5a | ||
|
|
2452c51d00 | ||
|
|
230813b355 | ||
|
|
7372e977b8 | ||
|
|
f68878cc44 | ||
|
|
b7079ead80 | ||
|
|
255330b9bc | ||
|
|
dc6018ed5a | ||
|
|
b80193f726 | ||
|
|
b6bae3dba6 | ||
|
|
552504b834 | ||
|
|
e6987459a5 | ||
|
|
2529052749 | ||
|
|
d5df838976 | ||
|
|
e095c710f9 | ||
|
|
a32744255f | ||
|
|
054a713977 | ||
|
|
f86bc34dd5 | ||
|
|
774b7c7e04 | ||
|
|
e52afe6349 | ||
|
|
0ba81a994b | ||
|
|
d35a52a3f1 | ||
|
|
f0ba7c5da9 | ||
|
|
632d6a2f8e | ||
|
|
27d78801d0 | ||
|
|
02b374f504 | ||
|
|
1ba4c23a58 | ||
|
|
b4d6effbca | ||
|
|
f293c6e9dc | ||
|
|
449438fb20 | ||
|
|
a8437e69e5 | ||
|
|
23e55d4929 | ||
|
|
0a95a1cd65 | ||
|
|
62d0f50593 | ||
|
|
27ba3f2ee0 | ||
|
|
15e047949e | ||
|
|
744b4a8398 | ||
|
|
d90725815a | ||
|
|
a977f2c23e | ||
|
|
fa319e37b3 | ||
|
|
035399e7a6 | ||
|
|
59f0e49c6e | ||
|
|
fa91f3a731 | ||
|
|
524908b23b | ||
|
|
2bf929e34e | ||
|
|
a72989c4bb | ||
|
|
93bacc9696 | ||
|
|
7890005a92 | ||
|
|
d17bcc05fb | ||
|
|
2b3959e55b | ||
|
|
43b52826e4 | ||
|
|
9f3511f2dc | ||
|
|
3fafd9f2b6 | ||
|
|
857369b18b | ||
|
|
f8e089bdf2 | ||
|
|
b921ba7184 | ||
|
|
4fea0d2664 | ||
|
|
70fe860bac | ||
|
|
2eb9fdbf68 | ||
|
|
82a48ee0ce | ||
|
|
0f234f769d | ||
|
|
c70aab8bbf | ||
|
|
b7747bc7d8 | ||
|
|
345e613a4a | ||
|
|
66ae952c2a | ||
|
|
dc72141af6 | ||
|
|
79ee16546d | ||
|
|
baf283c50f | ||
|
|
8741f3f6d7 | ||
|
|
72acafd7eb | ||
|
|
92b83d9525 | ||
|
|
be54edf988 | ||
|
|
ee4bbf8f1f | ||
|
|
44ee23ee33 | ||
|
|
7bb55c2a41 | ||
|
|
c1cc94f0e0 | ||
|
|
d071ac2e14 | ||
|
|
040ad3dc5d | ||
|
|
22c7d6340a | ||
|
|
14396ed0b1 | ||
|
|
7752329d71 | ||
|
|
b666ac8985 | ||
|
|
1fbf7704ca | ||
|
|
c80f478df9 | ||
|
|
10d32312b5 | ||
|
|
c8f9b5d3b4 | ||
|
|
7dc7597260 | ||
|
|
fe92ce067a | ||
|
|
1d151b5565 | ||
|
|
af62bd4c64 | ||
|
|
4de5c0c0b5 | ||
|
|
067b944337 | ||
|
|
1514899746 | ||
|
|
0ca56b4450 | ||
|
|
51e1643762 | ||
|
|
913420df68 | ||
|
|
1babdfb558 | ||
|
|
4a5bf2290a | ||
|
|
9d26ffe657 | ||
|
|
adb1727c50 | ||
|
|
0f97e3e639 | ||
|
|
9d1d27325d | ||
|
|
32552224ea | ||
|
|
b0c4e7c32e | ||
|
|
47469bab22 | ||
|
|
14e56c0399 | ||
|
|
2b63dd8548 | ||
|
|
bf3e8a9b3a | ||
|
|
ee6ca4f3c9 | ||
|
|
284644acc8 | ||
|
|
6a905d9ac8 | ||
|
|
08ff4fbefd | ||
|
|
3c860d8159 | ||
|
|
959dda9e48 | ||
|
|
de8482fde0 | ||
|
|
5315ad297d | ||
|
|
77bf22f111 | ||
|
|
007efaee28 | ||
|
|
093ea906ae | ||
|
|
37879e0c82 | ||
|
|
fe790ca01f | ||
|
|
b38bd0c8b5 | ||
|
|
c9f8b50cd6 | ||
|
|
49bae62e9a | ||
|
|
f855cee891 | ||
|
|
d9bd5a446c | ||
|
|
8c61fe79dc | ||
|
|
a50cbd8f91 | ||
|
|
f234855e66 | ||
|
|
a2d6371545 | ||
|
|
86c765d99e | ||
|
|
bc6b243f56 | ||
|
|
1a6b12af86 | ||
|
|
5ac596f93c | ||
|
|
9beb6d45a0 | ||
|
|
53b20e8c45 | ||
|
|
f10dd1ebba | ||
|
|
15bc6bcf5a | ||
|
|
4051899b9b | ||
|
|
965bbb05a8 | ||
|
|
efa2a50665 | ||
|
|
5a38bea3ff | ||
|
|
0c93344913 | ||
|
|
0672c2b2ff | ||
|
|
50e67c0988 | ||
|
|
b1f1f6b754 | ||
|
|
a3397329d5 | ||
|
|
368ea35f7f | ||
|
|
9cf848c143 | ||
|
|
eecb5291d0 | ||
|
|
4366a1c796 | ||
|
|
44aa358060 | ||
|
|
5df8cfc0c1 | ||
|
|
46edcb1f37 | ||
|
|
0b55163dc3 | ||
|
|
2cfb6d2169 | ||
|
|
78698a075a | ||
|
|
58ba44dbac | ||
|
|
6647ef5677 | ||
|
|
e08a7b4886 | ||
|
|
1dba818581 | ||
|
|
fb615f9d5d | ||
|
|
ee0f97c85c | ||
|
|
351fef43d6 | ||
|
|
e29dfdeb72 | ||
|
|
edfe8af52a | ||
|
|
0d969a5348 | ||
|
|
8a4ecdea63 | ||
|
|
551ff2f563 | ||
|
|
0b29ccb91d | ||
|
|
0d047450ce | ||
|
|
0e0a0e54e5 | ||
|
|
e95936cdce | ||
|
|
59fdae817a | ||
|
|
d36b7f80c3 | ||
|
|
1241b9499c | ||
|
|
9f82960c2c | ||
|
|
2a21ff65d4 | ||
|
|
37aed2c9b1 | ||
|
|
61292c9ea0 | ||
|
|
e8e5c863e4 | ||
|
|
52a8f58909 | ||
|
|
4b27268e2f | ||
|
|
4c9e013276 | ||
|
|
6569dced23 | ||
|
|
84cffb3f92 | ||
|
|
fc70a3fcf4 | ||
|
|
4382817e55 | ||
|
|
9bbf6fb248 | ||
|
|
d70b67abc4 |
+28
-14
@@ -30,20 +30,14 @@ else
|
||||
fi
|
||||
|
||||
status=0
|
||||
gate_status=0
|
||||
|
||||
# Локальный набор гейтов (#343). Выключен по умолчанию намеренно: 20-45 секунд на
|
||||
# каждый пуш, включая пуши одной строки документации, — цена, которую стоит
|
||||
# платить осознанно. Документация: docs/TESTING.md.
|
||||
if [ "${HP_PREPUSH_GATE:-}" = "1" ] && [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then
|
||||
echo "pre-push-gate: HP_PREPUSH_GATE=1, прогоняю локальный набор" >&2
|
||||
if ! node "$repo_root/scripts/pre-push-gate.mjs" >&2; then
|
||||
status=1
|
||||
fi
|
||||
fi
|
||||
# Строки git читаются один раз: их нужно и локальному набору, и процессному гейту.
|
||||
refs=$(cat)
|
||||
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
# Deleting a remote branch pushes nothing to examine.
|
||||
if [ "$local_sha" = "$zero" ]; then
|
||||
# An empty line (nothing on stdin) and deleting a remote branch push nothing to examine.
|
||||
if [ -z "$local_sha" ] || [ "$local_sha" = "$zero" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -71,10 +65,25 @@ while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
if ! node "$gate" --range "${base}..${local_sha}" --target-ref "$remote_ref" $issues_flag >&2; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
done <<EOF
|
||||
$refs
|
||||
EOF
|
||||
|
||||
# Локальный набор gate:small (#633, прежде opt-in #343) — после процессного
|
||||
# гейта: тот отвечает за секунды, а набор идёт минуты; прогоняются оба, чтобы
|
||||
# автор узнал обо всех провалах одним кругом. По умолчанию включён для
|
||||
# веток issue/* с исполняемым диффом; дифф только класса C/D (документы ревью,
|
||||
# changelog, бандл) и ветки вне issue/* его не запускают. Выключение — явное:
|
||||
# HP_PREPUSH_GATE=0; HP_PREPUSH_GATE=1 — прогнать для любой ветки. Документация:
|
||||
# docs/TESTING.md, docs/DEVELOPMENT.md «Локальный контур за 5 минут».
|
||||
if [ -f "$repo_root/scripts/pre-push-gate.mjs" ]; then
|
||||
if ! printf '%s\n' "$refs" | node "$repo_root/scripts/pre-push-gate.mjs" --hook >&2; then
|
||||
gate_status=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
cat >&2 <<'EOF'
|
||||
cat >&2 <<'MSG'
|
||||
|
||||
Push остановлен: нарушен процесс (PROCESS.md §10.2).
|
||||
|
||||
@@ -84,7 +93,12 @@ Push остановлен: нарушен процесс (PROCESS.md §10.2).
|
||||
|
||||
Обойти проверку можно через `git push --no-verify`, и тогда то же самое найдёт
|
||||
job `process-gate` в Validate — уже после того, как код окажется в dev.
|
||||
EOF
|
||||
MSG
|
||||
fi
|
||||
|
||||
if [ "$gate_status" -ne 0 ]; then
|
||||
echo "Push остановлен: красный локальный набор gate:small (см. вывод выше)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
name: "Мутационный гейт · тело (#623)"
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
|
||||
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
|
||||
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
|
||||
# Мутанты, задетые диффом, конвейер ревью и слияние гоняют отдельно на своём
|
||||
# кандидате (#510); кандидат беты, `full=true` и ночной Validate их не
|
||||
# запрашивают (#601) — ночью достаточно этого полного реестра.
|
||||
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
|
||||
# обычными юнитами: test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
|
||||
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
|
||||
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
|
||||
#
|
||||
# #620: ночь по расписанию на дереве, уже доказанном зелёным полным прогоном
|
||||
# (тот же tree материала, тот же SHA workflow, маркер не старше недели), шарды не
|
||||
# гоняет — в сводке «reused from run N». Маркер пишет только зелёный агрегатор,
|
||||
# поэтому красный не переносится: следующая ночь гонит реестр заново и снова
|
||||
# заводит issue (#472). Ручной dispatch гонит полный реестр всегда. Решение —
|
||||
# чистая функция scripts/mutation-nightly-reuse.mjs.
|
||||
#
|
||||
# #623: «SHA workflow» — `job.workflow_sha`, SHA этого файла-тела. В вызываемом
|
||||
# workflow `github.workflow_sha` принадлежит вызывающему `mutation-gate.yml` из
|
||||
# main и не меняется вместе с телом, поэтому здесь он не используется.
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `mutation-gate.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Git ref whose mutation guards must be proved"
|
||||
required: false
|
||||
type: string
|
||||
default: dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency уровня workflow — у вызывающего `mutation-gate.yml` (#623).
|
||||
|
||||
jobs:
|
||||
# #549: moving ref разрешается ровно один раз. Все шарды ниже получают один
|
||||
# commit/tree, а не самостоятельно читают dev в разное время.
|
||||
material:
|
||||
name: "Зафиксировать неизменяемый материал"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
sha: ${{ steps.identity.outputs.sha }}
|
||||
tree: ${{ steps.identity.outputs.tree }}
|
||||
ref: ${{ steps.identity.outputs.ref }}
|
||||
reuse: ${{ steps.reuse.outputs.reuse }}
|
||||
reused_run: ${{ steps.reuse.outputs.reused_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
fetch-depth: 0
|
||||
- name: Зафиксировать commit и tree
|
||||
id: identity
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
|
||||
# #620: самый свежий маркер зелёного прогона этого tree и этого workflow.
|
||||
# Ключ уникален на прогон (кэш неизменяем), восстанавливается по префиксу —
|
||||
# иначе перепроверка после недели не смогла бы освежить маркер.
|
||||
- name: Маркер зелёного прогона этого дерева
|
||||
if: github.event_name == 'schedule'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: artifacts/mutation-green
|
||||
key: mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ job.workflow_sha }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ job.workflow_sha }}-
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
# Любой сбой решения — полный прогон: пропуск должен быть доказан, а не
|
||||
# выведен из ошибки. Материал без скрипта (dispatch старого ref) — тоже.
|
||||
- name: Нужен ли прогон
|
||||
id: reuse
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TREE: ${{ steps.identity.outputs.tree }}
|
||||
WORKFLOW_SHA: ${{ job.workflow_sha }}
|
||||
RUN_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/actions/runs
|
||||
run: |
|
||||
decision="$RUNNER_TEMP/mutation-reuse.txt"
|
||||
if [ -f scripts/mutation-nightly-reuse.mjs ] && node scripts/mutation-nightly-reuse.mjs --decide \
|
||||
--event="$EVENT_NAME" --tree="$TREE" --workflow-sha="$WORKFLOW_SHA" \
|
||||
--marker=artifacts/mutation-green/marker.json \
|
||||
--summary="$GITHUB_STEP_SUMMARY" --run-url-base="$RUN_URL_BASE" > "$decision"; then
|
||||
cat "$decision" >> "$GITHUB_OUTPUT"
|
||||
if grep -qx 'reuse=true' "$decision"; then
|
||||
echo "::notice::reused from run $(sed -n 's/^reused_run=//p' "$decision")"
|
||||
fi
|
||||
else
|
||||
echo "::warning::решение о повторном использовании не получено — полный прогон"
|
||||
echo "reuse=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
|
||||
needs: material
|
||||
if: needs.material.outputs.reuse != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
# Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из
|
||||
# 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард
|
||||
# 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у
|
||||
# `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`,
|
||||
# `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их
|
||||
# равными. Бандл собирают только браузерные гварды. Час — потолок против
|
||||
# зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал
|
||||
# снова делить, и отчёт (#472) называет такой шард прерванным, не «ok».
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
|
||||
- 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 --with-deps chromium
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
# Вывод шарда сохраняется артефактом (#472): строки
|
||||
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
id: gate
|
||||
run: |
|
||||
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
# Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт
|
||||
# `cancelled`, и агрегатор отвергает такой шард как неполный — лог без
|
||||
# строк FAIL сам по себе зелёным не считается.
|
||||
- name: Записать identity шарда
|
||||
if: always()
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--shard=${{ matrix.shard }} --shards=6 \
|
||||
--outcome=${{ steps.gate.outcome }}
|
||||
- name: Сохранить лог и identity шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Результат нельзя приписывать material, пока не доказаны все шесть шардов —
|
||||
# каждый с identity и с дошедшим до конца прогоном (#549, #604).
|
||||
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
|
||||
evidence:
|
||||
name: "Доказать единый material всех шардов"
|
||||
needs: [material, mutants]
|
||||
if: always() && needs.material.outputs.reuse != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# CLI отчётчика принадлежит зафиксированному material. В main лежит
|
||||
# только workflow и может не быть dev-версии scripts/**.
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать evidence всех попыток
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Проверить полноту и identity
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs --verify-only \
|
||||
--logs=artifacts/mutation-logs --shards=6 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }}
|
||||
|
||||
# #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов
|
||||
# доказаны на одном material. Красный или неполный прогон маркера не оставляет,
|
||||
# и следующая ночь гонит реестр заново.
|
||||
green_marker:
|
||||
name: "Записать маркер зелёного прогона"
|
||||
needs: [material, mutants, evidence]
|
||||
if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Маркер
|
||||
run: |
|
||||
node scripts/mutation-nightly-reuse.mjs \
|
||||
--write-marker=artifacts/mutation-green/marker.json \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--event=${{ github.event_name }}
|
||||
- name: Сохранить маркер
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: artifacts/mutation-green
|
||||
key: mutation-green-v1-${{ needs.material.outputs.tree }}-${{ job.workflow_sha }}-${{ github.run_id }}
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
|
||||
# для отладки самого гейта, его результат смотрят в прогоне — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: [material, mutants, evidence]
|
||||
# #620: ночь, принявшая доказательство прошлого зелёного прогона, отказом не
|
||||
# является — её шарды пропущены намеренно. Любой другой пропуск шардов
|
||||
# (упал material) по-прежнему заводит issue.
|
||||
if: always() && github.event_name == 'schedule' && needs.material.outputs.reuse != 'true' && (needs.mutants.result != 'success' || needs.evidence.result != 'success')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# Не перечитываем moving dev: код отчётчика берётся из уже
|
||||
# зафиксированного material. В стабильном main dev-скриптов нет.
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Собрать отчёт
|
||||
id: report
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=6 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ job.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
# получает комментарий, новое заводится только если открытого нет.
|
||||
- name: Issue — создать или дописать
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
MARKER: ${{ steps.report.outputs.marker }}
|
||||
TITLE: ${{ steps.report.outputs.title }}
|
||||
run: |
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
|
||||
url="${{ github.server_url }}/$REPO/issues/$existing"
|
||||
else
|
||||
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
|
||||
--label infra --label process --label tests \
|
||||
--body-file artifacts/mutation-report.md)
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "issue: $url"
|
||||
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
|
||||
# issue уже заведено, а Telegram — второй адресат, не единственный.
|
||||
- name: Telegram
|
||||
if: always() && steps.issue.outcome == 'success'
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
|
||||
exit 0
|
||||
fi
|
||||
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
@@ -0,0 +1,84 @@
|
||||
# Ночной полный прогон (#479).
|
||||
#
|
||||
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
|
||||
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
|
||||
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
|
||||
#
|
||||
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
|
||||
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
|
||||
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
|
||||
# при неизменённом дереве ночной прогон обойдётся маркерами.
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
name: "Ночной полный прогон dev · тело (#623)"
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `nightly.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
# #658: при плане 02:30 UTC ночь фактически стартовала в 07:42–08:05 и
|
||||
# кончалась в рабочее время владельца. Сдвиг старта больше часа — видимое
|
||||
# предупреждение, а не молча съеденная ночь. `github.event.schedule` —
|
||||
# строка cron вызывающего `nightly.yml`; NOW_EPOCH подставляет только тест.
|
||||
- name: "Сдвиг старта ночи против расписания"
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r minute hour _ <<< "$SCHEDULE"
|
||||
now=${NOW_EPOCH:-$(date -u +%s)}
|
||||
planned=$(date -u -d "@$now" +%F)
|
||||
planned=$(date -u -d "$planned $hour:$minute" +%s)
|
||||
[ "$planned" -le "$now" ] || planned=$((planned - 86400))
|
||||
lag=$(( (now - planned) / 60 ))
|
||||
echo "- Старт по расписанию \`$SCHEDULE\` (UTC): сдвиг $lag мин" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$lag" -gt 60 ]; then
|
||||
echo "::warning::ночной прогон стартовал на $lag мин позже расписания ($SCHEDULE UTC) — очередь расписаний GitHub (#658)"
|
||||
fi
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
since=$(date -u +%FT%TZ)
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $since"
|
||||
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
|
||||
# раньше момента запуска. До трёх минут на появление в списке.
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow validate.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 "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
|
||||
exit 1
|
||||
fi
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
@@ -0,0 +1,59 @@
|
||||
name: "Метрики процесса · тело (#623)"
|
||||
|
||||
# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью,
|
||||
# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт
|
||||
# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется.
|
||||
# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate
|
||||
# за неделю) были собраны руками за час — теперь они стоят один запуск.
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-metrics.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
inputs:
|
||||
days:
|
||||
description: "Окно в днях"
|
||||
required: false
|
||||
type: string
|
||||
default: "7"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
metrics:
|
||||
name: "Снимок недели: issue, раунды, прогоны"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# Код — из dev, как у reconcile: расписание читается из main, а исполняется
|
||||
# версия, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Собрать отчёт
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
DAYS: ${{ inputs.days || '7' }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-metrics
|
||||
node scripts/process-metrics.mjs --repo="$REPO" --days="$DAYS" \
|
||||
--output=artifacts/process-metrics/report.md \
|
||||
--json=artifacts/process-metrics/report.json > /dev/null
|
||||
cat artifacts/process-metrics/report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Сохранить отчёт
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-metrics-${{ github.run_id }}
|
||||
path: artifacts/process-metrics
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
@@ -0,0 +1,57 @@
|
||||
name: "Сверка очереди ревью · тело (#623)"
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-reconcile.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
inputs:
|
||||
apply:
|
||||
description: "Повторно будить потерянные запросы и публиковать диагностику"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
name: "Один снимок S4/S7 без polling модели"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Расписание читается из main, а исполняемый reconciler — из dev: так
|
||||
# после штатного merge действует та же версия кода, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Сопоставить labels, requests, runs и sealed evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-reconcile
|
||||
node scripts/process-reconcile.mjs \
|
||||
--repo "$REPO" \
|
||||
--apply="$APPLY" \
|
||||
--max-actions=5 \
|
||||
--output=artifacts/process-reconcile/summary.json
|
||||
- name: Опубликовать компактный machine-readable итог
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/process-reconcile/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "Продолжение ревью после Validate · тело (#623)"
|
||||
|
||||
# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с
|
||||
# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при
|
||||
# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный
|
||||
# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение
|
||||
# любого Validate и, если раунд ждал именно этот прогон (маркер на материале,
|
||||
# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 —
|
||||
# новый прогон `prepare` находит завершённый dispatch и продолжает раунд.
|
||||
# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на
|
||||
# потерянное событие — process-reconcile.yml с тем же маркером.
|
||||
#
|
||||
# Для события `workflow_run` GitHub берёт workflow только из ветки по
|
||||
# умолчанию (main). Там лежит тонкий `process-resume.yml`, который вызывает
|
||||
# этот файл по ссылке `@dev` (#623); сверка тонких копий — в preflight
|
||||
# validate.yml.
|
||||
|
||||
on:
|
||||
# #623: тело вызывается тонким файлом `process-resume.yml` из ветки по умолчанию
|
||||
# по ссылке `@dev`; триггеры, run-name и concurrency живут там.
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
resume:
|
||||
name: "Разбудить раунд, ждавший этот Validate"
|
||||
if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-resume-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Код берётся из dev, как у reconcile: после штатного слияния действует
|
||||
# версия, которую проверил CI, а не копия из main.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
# Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не
|
||||
# запустило бы process.yml (см. шапку process.yml, п. 1).
|
||||
- name: Решить по маркеру ожидания и переставить S7
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
EVENT: ${{ github.event.workflow_run.event }}
|
||||
STATUS: ${{ github.event.workflow_run.status }}
|
||||
run: |
|
||||
node scripts/process-resume.mjs \
|
||||
--repo="$REPO" --branch="$BRANCH" --sha="$SHA" \
|
||||
--event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,8 @@ jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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.
|
||||
#
|
||||
# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся
|
||||
# для просмотра: решение, какие кадры сдвинулись законно, принимает человек.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
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:
|
||||
group: beta-derived
|
||||
cancel-in-progress: false
|
||||
|
||||
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
|
||||
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"
|
||||
@@ -31,7 +31,8 @@ permissions:
|
||||
jobs:
|
||||
capture:
|
||||
name: Съёмка и сверка скриншот-индекса
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
name: Мутационный гейт
|
||||
|
||||
# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код
|
||||
# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест,
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
|
||||
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
|
||||
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
|
||||
# Мутанты, задетые диффом, конвейер ревью и слияние гоняют отдельно на своём
|
||||
# кандидате (#510); кандидат беты, `full=true` и ночной Validate их не
|
||||
# запрашивают (#601) — ночью достаточно этого полного реестра.
|
||||
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
|
||||
# обычными юнитами: test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
# tsc), а реестр режется на четыре чересполосных шарда — полный прогон
|
||||
# укладывается в десятки минут вместо часов. Локальный дифф-режим:
|
||||
# node scripts/mutation-gate.mjs --changed origin/dev..HEAD
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_mutation-gate.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -28,9 +16,10 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
schedule:
|
||||
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
|
||||
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
|
||||
- cron: '0 1 * * *'
|
||||
# Каждую ночь, 00:43 UTC (03:43 MSK) — после суток правок и до ночного
|
||||
# полного Validate (nightly.yml, 02:17 UTC), чтобы не делить раннеры (#513).
|
||||
# Минута не круглая: такие старты GitHub сдвигает меньше (#658).
|
||||
- cron: '43 0 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -45,233 +34,15 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# #549: moving ref разрешается ровно один раз. Все шарды ниже получают один
|
||||
# commit/tree, а не самостоятельно читают dev в разное время.
|
||||
material:
|
||||
name: "Зафиксировать неизменяемый материал"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.identity.outputs.sha }}
|
||||
tree: ${{ steps.identity.outputs.tree }}
|
||||
ref: ${{ steps.identity.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
fetch-depth: 0
|
||||
- name: Зафиксировать commit и tree
|
||||
id: identity
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
|
||||
needs: material
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
# Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из
|
||||
# 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард
|
||||
# 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у
|
||||
# `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`,
|
||||
# `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их
|
||||
# равными. Бандл собирают только браузерные гварды. Час — потолок против
|
||||
# зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал
|
||||
# снова делить, и отчёт (#472) называет такой шард прерванным, не «ok».
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||||
# в разные дни.
|
||||
pip install -r tests_backend/requirements.txt
|
||||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||||
|
||||
- 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 --with-deps chromium
|
||||
|
||||
- name: Реестр применим к текущему коду
|
||||
run: node scripts/mutation-gate.mjs --check
|
||||
|
||||
- name: Тёплый test-build для инкрементальной компиляции мутантов
|
||||
run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
|
||||
# Вывод шарда сохраняется артефактом (#472): строки
|
||||
# `FAIL <id>: тест остался зелёным…` — единственное место, где названо,
|
||||
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
id: gate
|
||||
run: |
|
||||
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
# Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт
|
||||
# `cancelled`, и агрегатор отвергает такой шард как неполный — лог без
|
||||
# строк FAIL сам по себе зелёным не считается.
|
||||
- name: Записать identity шарда
|
||||
if: always()
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--shard=${{ matrix.shard }} --shards=6 \
|
||||
--outcome=${{ steps.gate.outcome }}
|
||||
- name: Сохранить лог и identity шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Результат нельзя приписывать material, пока не доказаны все шесть шардов —
|
||||
# каждый с identity и с дошедшим до конца прогоном (#549, #604).
|
||||
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
|
||||
evidence:
|
||||
name: "Доказать единый material всех шардов"
|
||||
needs: [material, mutants]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# CLI отчётчика принадлежит зафиксированному material. В main лежит
|
||||
# только workflow и может не быть dev-версии scripts/**.
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать evidence всех попыток
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Проверить полноту и identity
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs --verify-only \
|
||||
--logs=artifacts/mutation-logs --shards=6 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }}
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
|
||||
# для отладки самого гейта, его результат смотрят в прогоне — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: [material, mutants, evidence]
|
||||
if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success')
|
||||
runs-on: ubuntu-latest
|
||||
# Потолок прав тела: объединение job-level прав `_mutation-gate.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# Не перечитываем moving dev: код отчётчика берётся из уже
|
||||
# зафиксированного material. В стабильном main dev-скриптов нет.
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Собрать отчёт
|
||||
id: report
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=6 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
# получает комментарий, новое заводится только если открытого нет.
|
||||
- name: Issue — создать или дописать
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
MARKER: ${{ steps.report.outputs.marker }}
|
||||
TITLE: ${{ steps.report.outputs.title }}
|
||||
run: |
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty')
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md
|
||||
url="${{ github.server_url }}/$REPO/issues/$existing"
|
||||
else
|
||||
url=$(gh issue create --repo "$REPO" --title "$TITLE" \
|
||||
--label infra --label process --label tests \
|
||||
--body-file artifacts/mutation-report.md)
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "issue: $url"
|
||||
# Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ:
|
||||
# issue уже заведено, а Telegram — второй адресат, не единственный.
|
||||
- name: Telegram
|
||||
if: always() && steps.issue.outcome == 'success'
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
ISSUE_URL: ${{ steps.issue.outputs.url }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено"
|
||||
exit 0
|
||||
fi
|
||||
TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt)
|
||||
curl -sS --fail-with-body -X POST \
|
||||
"https://api.telegram.org/bot$TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=$TEXT" \
|
||||
-d disable_web_page_preview=true
|
||||
uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev # #623: тело конвейера из dev
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
# Ночной полный прогон (#479).
|
||||
#
|
||||
# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не
|
||||
# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут
|
||||
# критического пути на каждую итерацию. Полный набор идёт на кандидате беты
|
||||
# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`.
|
||||
#
|
||||
# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке
|
||||
# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev`
|
||||
# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется:
|
||||
# при неизменённом дереве ночной прогон обойдётся маркерами.
|
||||
#
|
||||
# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт:
|
||||
# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и
|
||||
# там полный набор идёт заново.
|
||||
#
|
||||
# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась
|
||||
# успехом в момент постановки Validate в очередь, и красный полный прогон не
|
||||
# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт
|
||||
# его: успешный dispatch — не успешная проверка.
|
||||
name: Ночной полный прогон dev
|
||||
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_nightly.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
# 02:17 UTC, не на круглой минуте: старты «ровно в час/полчаса» GitHub
|
||||
# откладывает на часы (факт — 07:42–08:05 при плане 02:30, #658).
|
||||
schedule:
|
||||
- cron: '30 2 * * *'
|
||||
- cron: '17 2 * * *'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -30,35 +20,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: "Запустить Validate на dev с полным набором и дождаться результата"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
since=$(date -u +%FT%TZ)
|
||||
gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true
|
||||
echo "Validate(dev, full=true) поставлен в очередь: $since"
|
||||
# Найти именно этот прогон: workflow_dispatch на dev, созданный не
|
||||
# раньше момента запуска. До трёх минут на появление в списке.
|
||||
run_id=""
|
||||
for _ in $(seq 1 18); do
|
||||
sleep 10
|
||||
run_id=$(gh run list --repo "$REPO" --workflow validate.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 "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке"
|
||||
exit 1
|
||||
fi
|
||||
url="${{ github.server_url }}/$REPO/actions/runs/$run_id"
|
||||
echo "дочерний прогон: $url"
|
||||
echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Ждём завершения; красный дочерний прогон — красный ночной.
|
||||
gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30
|
||||
# Потолок прав тела: объединение job-level прав `_nightly.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_nightly.yml@dev # #623: тело конвейера из dev
|
||||
secrets: inherit
|
||||
|
||||
@@ -6,8 +6,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- ".github/workflows/**"
|
||||
- "docs/**"
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
# Минута не круглая (#658): доходит до main вместе со стабильным релизом.
|
||||
- cron: "11 4 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
comparison_ref:
|
||||
@@ -28,7 +32,7 @@ jobs:
|
||||
# Every profile keeps base and candidate sequential on one hosted runner.
|
||||
# Independent profile pairs run in parallel: cross-profile timing is never
|
||||
# compared, while serialising all profile pairs cannot fit the job timeout.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Метрики процесса
|
||||
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_process-metrics.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Понедельник 05:23 UTC — после ночных прогонов, до рабочего дня; минута
|
||||
# не круглая, чтобы не стоять в очереди «ровных» расписаний (#658).
|
||||
- cron: '23 5 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days:
|
||||
description: "Окно в днях"
|
||||
required: false
|
||||
default: "7"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
# Потолок прав тела: объединение job-level прав `_process-metrics.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_process-metrics.yml@dev # #623: тело конвейера из dev
|
||||
with:
|
||||
days: ${{ inputs.days }}
|
||||
secrets: inherit
|
||||
@@ -1,6 +1,14 @@
|
||||
name: Сверка очереди ревью
|
||||
run-name: "reconcile process queue · ${{ github.event_name }}"
|
||||
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_process-reconcile.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7,37 * * * *'
|
||||
@@ -18,41 +26,15 @@ permissions:
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
name: "Один снимок S4/S7 без polling модели"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Расписание читается из main, а исполняемый reconciler — из dev: так
|
||||
# после штатного merge действует та же версия кода, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Сопоставить labels, requests, runs и sealed evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-reconcile
|
||||
node scripts/process-reconcile.mjs \
|
||||
--repo "$REPO" \
|
||||
--apply="$APPLY" \
|
||||
--max-actions=5 \
|
||||
--output=artifacts/process-reconcile/summary.json
|
||||
- name: Опубликовать компактный machine-readable итог
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/process-reconcile/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Потолок прав тела: объединение job-level прав `_process-reconcile.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_process-reconcile.yml@dev # #623: тело конвейера из dev
|
||||
with:
|
||||
apply: ${{ inputs.apply == true }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Продолжение ревью после Validate
|
||||
run-name: "resume · ${{ github.event.workflow_run.head_branch }} · ${{ github.event.workflow_run.conclusion }}"
|
||||
|
||||
# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из
|
||||
# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там:
|
||||
# триггеры, run-name, права и concurrency. Тело — `_process-resume.yml` по ссылке
|
||||
# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно.
|
||||
# Этот файл меняется, только когда меняются сами триггеры или потолок прав;
|
||||
# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml)
|
||||
# держит копии равными.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Проверка (CI)"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Потолок прав тела: объединение job-level прав `_process-resume.yml`. Вызываемый
|
||||
# workflow может права только сузить, поэтому каждая его job по-прежнему
|
||||
# получает свой прежний минимум (#556), а шире этого набора не получит никто.
|
||||
dev:
|
||||
# Тот же фильтр, что у job `resume` тела: посторонние события не
|
||||
# поднимают вызов, а прогон остаётся skipped, как до #623.
|
||||
if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/')
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
uses: Matysh/houseplan-card/.github/workflows/_process-resume.yml@dev # #623: тело конвейера из dev
|
||||
secrets: inherit
|
||||
+15
-1583
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ concurrency:
|
||||
jobs:
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка и релизный контракт"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# Больше ожидания зелёного Validate в release-gate.mjs (до 60 мин) (#658).
|
||||
timeout-minutes: 75
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
@@ -89,6 +91,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
# #696, PROCESS.md §11.7: задачи track:ship слиты без ревью модели — бета
|
||||
# публикуется, только когда их код прочитало пакетное ревью диапазона.
|
||||
- name: Ship tasks of the range are batch-reviewed
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.candidate.outputs.tag }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/ship-review.mjs check --tag="$TAG" --candidate="$SHA" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Bind issue membership to the exact candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -122,7 +132,8 @@ jobs:
|
||||
publish:
|
||||
name: Публикация тега и релиза
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
@@ -289,7 +300,8 @@ jobs:
|
||||
close-merged:
|
||||
name: Закрытие вошедших issue
|
||||
needs: [gate, publish]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
name: "Релиз: независимое ревью линии"
|
||||
run-name: "Release review ${{ inputs.tag }}"
|
||||
|
||||
# #638, PROCESS.md §11.5: перед стабильным релизом — одно независимое ревью
|
||||
# поверхностей, изменённых всей линией бет, «с нуля»: без ТЗ и документов
|
||||
# раундов, по SCOPE и USER-GUIDE. Инкрементальное ревью судит дифф задачи
|
||||
# против её ТЗ; четыре дефекта линии 1.77 (#607, #608, #611, #619) не входили
|
||||
# ни в один AC и нашлись только так.
|
||||
#
|
||||
# Решение владельца 2026-09-25: ревью НЕ блокирует выпуск. `release.yml`
|
||||
# запускает этот workflow параллельно гейтам из job, от которого не зависит ни
|
||||
# один job выпуска; документ — рекомендация, в работу его берёт владелец.
|
||||
#
|
||||
# Только `workflow_dispatch`: GitHub исполняет файл с той ветки, на которой
|
||||
# запущен прогон (`release.yml` зовёт `--ref dev`), поэтому зеркало в `main`
|
||||
# не нужно, а правка — один коммит в `dev`.
|
||||
#
|
||||
# Три job, как у конвейера (#551, #556): детерминированная подготовка,
|
||||
# недоверенная модель без единого права на запись, детерминированная
|
||||
# публикация документа в `dev`.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Stable release tag, for example v1.78.0"
|
||||
required: true
|
||||
type: string
|
||||
candidate:
|
||||
description: "Exact candidate SHA; empty = the commit of the tag"
|
||||
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:
|
||||
group: release-review-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: "Ревью релиза: вход линии"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
proceed: ${{ steps.line.outputs.proceed }}
|
||||
candidate: ${{ steps.line.outputs.candidate }}
|
||||
base: ${{ steps.line.outputs.base }}
|
||||
doc: ${{ steps.line.outputs.doc }}
|
||||
issues: ${{ steps.line.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: Кандидат, база и issue линии
|
||||
id: line
|
||||
env:
|
||||
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/release-review.mjs doc --tag="$TAG")
|
||||
git fetch -q --tags origin
|
||||
if [ -z "$CANDIDATE" ]; then
|
||||
CANDIDATE=$(git rev-parse --verify -q "refs/tags/$TAG^{commit}") || {
|
||||
echo "::error::тега $TAG нет, а кандидат не передан"; exit 1; }
|
||||
fi
|
||||
git cat-file -e "$CANDIDATE^{commit}"
|
||||
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
|
||||
# Повтор на тот же тег не тратит модель: документ уже есть (§11.5).
|
||||
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/release-review-input"
|
||||
node scripts/release-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \
|
||||
--out="$out" --run-url="$RUN_URL" | tee -a "$GITHUB_OUTPUT"
|
||||
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
(cd "$out" && sha256sum brief.md line-membership.json > manifest.sha256)
|
||||
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Передать вход модели
|
||||
if: steps.line.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: release-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/release-review-input
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
model_review:
|
||||
name: "Ревью релиза: работа модели"
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.proceed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
# Недоверенная стадия. Прав на запись нет никаких: ни в репозиторий, ни в
|
||||
# issue. Документ публикует `publish`; находки в issue превращает владелец.
|
||||
# `github_token` у шага Review обязателен (#556): без него 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: release-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/release-review-input
|
||||
|
||||
- name: Проверить вход и кандидата
|
||||
env:
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
run: |
|
||||
(cd "$RUNNER_TEMP/release-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 }}/release-review.md
|
||||
REVIEW_INPUT: ${{ runner.temp }}/release-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 || 'нет' }}.
|
||||
Вход линии — файл $REVIEW_INPUT/brief.md (issue линии, доказанные
|
||||
трейлерами, и изменённые продуктовые файлы) и
|
||||
$REVIEW_INPUT/line-membership.json.
|
||||
|
||||
Правила этого ревью — docs/process/REVIEWER.md, раздел «Независимое
|
||||
ревью линии», канон — PROCESS.md §11.5. Прочитай их первыми.
|
||||
|
||||
Главное, что нельзя пропустить:
|
||||
- Ты судишь ПОВЕРХНОСТИ, изменённые линией, против ПОЛЬЗОВАТЕЛЯ, а
|
||||
не дифф против ТЗ. Не читай ТЗ задач (раздел «## ТЗ» в issue,
|
||||
docs/specs/**) и документы раундов (docs/reviews/**): независимость
|
||||
и есть смысл шага. Основа суждения — docs/SCOPE.md (персоны и
|
||||
работы) и docs/USER-GUIDE.ru.md (что обещано пользователю).
|
||||
- Проверяй исполнением, а не чтением: собери бандл
|
||||
(`npm run bundle:sync`), открой затронутые поверхности в браузере
|
||||
(стенд `demo/`, смоки `demo/smoke_*.mjs`), вводи текст
|
||||
посимвольно, закрывай диалоги настоящим Escape и крестиком. Где
|
||||
есть диалоги HA — пиннутая фикстура `ha-dialog` (#505,
|
||||
demo/helpers/README-ha-dialog.md,
|
||||
`node demo/verify_ha_dialog_discard_recovery.mjs`).
|
||||
- Для каждой поверхности: обычный сценарий и самый рискованный
|
||||
соседний, шесть классов риска (async, данные/права, геометрия,
|
||||
визуал, объём/perf, host/input) — PROCESS.md §2.6.
|
||||
- Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
|
||||
комментарии. Права на запись у тебя нет. Любые изменения рабочей
|
||||
копии будут отброшены — после проверок восстанови её сам
|
||||
(`git checkout -- . && git clean -fd`), если что-то менял.
|
||||
|
||||
Находки: High / Medium / Low, у каждой — поверхность, воспроизведение
|
||||
(команда или шаги), что увидит пользователь, какая персона задета.
|
||||
Выпуск это ревью не останавливает: документ — рекомендация владельцу.
|
||||
|
||||
Напиши полный документ в файл по пути из переменной REVIEW_DOC
|
||||
(абсолютный, вне репозитория). Разделы: что входило в линию,
|
||||
поверхности и как каждая проверялась (команда → результат),
|
||||
находки, что проверено и корректно, чего не проверял и почему.
|
||||
Первой строкой после заголовка — `Итог: High N · Medium N · Low N`.
|
||||
|
||||
Затем верни JSON по схеме — последнее обязательное действие.
|
||||
claude_args: |
|
||||
--max-turns 200
|
||||
--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 }}/release-review.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
dir="$RUNNER_TEMP/release-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/release-review.md"
|
||||
(cd "$dir" && sha256sum release-review.md result.json > manifest.sha256)
|
||||
|
||||
- name: Передать результат публикации
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: release-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/release-review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: "Ревью релиза: документ в 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: release-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/release-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/release-review-result"
|
||||
(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")
|
||||
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/release-review.md"
|
||||
printf '\n\n<!-- hp-release-review-anchors -->\n### Материал ревью\n\n```\n'
|
||||
printf 'tag %s\ncandidate %s\nbase %s\nissues %s\nrun %s\n' \
|
||||
"$TAG" "$CANDIDATE" "${BASE:-—}" "${ISSUES:-—}" "$RUN_URL"
|
||||
printf '```\n'
|
||||
} > "$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: release review for $TAG
|
||||
|
||||
Независимое ревью линии перед стабильным релизом (PROCESS.md §11.5).
|
||||
Итог: $counts. Выпуск не блокирует; решение по находкам — за владельцем.
|
||||
|
||||
Issue: #638
|
||||
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 "### Независимое ревью $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Итог: $counts — \`$DOC\` в dev. Выпуск не блокируется." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: $counts"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
echo "::error::документ ревью не опубликован в dev за три попытки"
|
||||
exit 1
|
||||
@@ -48,7 +48,8 @@ jobs:
|
||||
name: "Кандидат: точный SHA, режим и черновик"
|
||||
# Публикация беты руками — не наш случай: у бет свой staged-путь.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
sha: ${{ steps.resolve.outputs.sha }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
@@ -131,10 +132,43 @@ jobs:
|
||||
echo "release $TAG is public: repair mode — only missing assets may be added"
|
||||
fi
|
||||
|
||||
independent-review:
|
||||
name: "Независимое ревью линии (не блокирует выпуск)"
|
||||
# #638, PROCESS.md §11.5. Решение владельца 2026-09-25: ревью идёт
|
||||
# параллельно гейтам и выпуск не ждёт и не останавливает. Поэтому этот job
|
||||
# только ставит в очередь `release-review.yml` на `dev` и ни один job
|
||||
# выпуска от него не зависит (`needs` на него запрещён тестом
|
||||
# release-workflow); его отказ — предупреждение, а не красный релиз.
|
||||
# Беты пропускаются: ревью линии — перед стабильным.
|
||||
needs: candidate
|
||||
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Поставить в очередь ревью линии
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
run: |
|
||||
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"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gate:
|
||||
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
|
||||
needs: candidate
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# Больше суммы собственных ожиданий job: Validate и Full Performance ждутся
|
||||
# до 60 мин каждый (release-gate.mjs), E2E — до 45 (e2e-gate.mjs): 165 < 180 (#658).
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -204,7 +238,8 @@ jobs:
|
||||
stage:
|
||||
name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"
|
||||
needs: [candidate, gate]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -344,7 +379,8 @@ jobs:
|
||||
publish:
|
||||
name: "Публикация и сверка публичных байтов"
|
||||
needs: [candidate, gate, stage]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
name: ${{ steps.verify.outputs.name }}
|
||||
@@ -444,7 +480,8 @@ jobs:
|
||||
# make that distribution failure impossible to miss in the release run.
|
||||
if: ${{ needs.candidate.outputs.prerelease == 'true' }}
|
||||
needs: [candidate, publish]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
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`.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
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:
|
||||
group: ship-review-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
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 }}
|
||||
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
|
||||
+171
-50
@@ -50,7 +50,8 @@ jobs:
|
||||
# действовать — на уровне шагов, с той же гранулярностью в логе.
|
||||
preflight:
|
||||
name: "Предполёт: документация, провенанс, процесс"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||||
# обращается к API сверх содержимого репозитория.
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
#
|
||||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||||
# `git show origin/main:.github/workflows/process.yml`: один блоб.
|
||||
# `git show origin/main:.github/workflows/<тонкий файл>`: по блобу на файл.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
@@ -83,6 +84,8 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
# #697: на ветке задачи `Release:` строгий режим не включает.
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
# #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой
|
||||
# `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)`
|
||||
@@ -92,25 +95,46 @@ jobs:
|
||||
echo "скриншоты документации: режим $mode"
|
||||
node scripts/check-docs.mjs --external --screenshots=$mode
|
||||
|
||||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||||
# #635: `docs/reviews/INDEX.md` — снимок каталога ревью; расхождение с
|
||||
# каталогом — невидимые через индекс документы (r2 #635 H1). Гейт стоит
|
||||
# только на push в dev: там свежесть держит конвейер — пересборка при
|
||||
# публикации документа и после каждого своего ребейза (приведение к dev,
|
||||
# слияние кандидата). На issue-ветках не судится: их ребейзит и
|
||||
# публикует в них конвейер из ветки по умолчанию, и доехавший туда
|
||||
# документ раунда индекс ветки знать не обязан — красить автора за
|
||||
# коммит конвейера нельзя; расхождение излечивает слияние.
|
||||
- name: "Индекс ревью совпадает с каталогом docs/reviews"
|
||||
id: reviews_index
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
continue-on-error: true
|
||||
run: node scripts/reviews-index.mjs --dir=docs/reviews --check
|
||||
|
||||
# #623: для событий `issues`, `schedule` и `workflow_run` GitHub берёт
|
||||
# workflow из ветки по умолчанию (`main`). Там лежат тонкие вызывающие
|
||||
# файлы — триггеры, run-name, права, concurrency, — а тело каждого
|
||||
# (`_<имя>.yml`) они вызывают по ссылке `@dev`. Правка конвейера — один
|
||||
# коммит в `dev`; тела здесь не сверяются: их копия в `main` не исполняется
|
||||
# ни одним событием. Тонкий файл сверяется: правка триггера или потолка
|
||||
# прав, не доехавшая до `main`, действовала бы только в dev-копии.
|
||||
#
|
||||
# `performance.yml` не входит: по расписанию он судит `main` собственным
|
||||
# телом из `main`, расхождение с `dev` до промоушена законно. Список ниже
|
||||
# держит равным множеству файлов с такими триггерами
|
||||
# test/default-branch-workflows.test.mjs.
|
||||
- name: "Процесс: тонкие вызывающие workflow идентичны в main и dev"
|
||||
id: workflow_sync
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --quiet origin main dev
|
||||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||||
status=0
|
||||
for file in process.yml mutation-gate.yml; do
|
||||
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do
|
||||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||||
<(git show "origin/dev:.github/workflows/$file"); then
|
||||
echo "$file: main и dev идентичны"
|
||||
else
|
||||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||||
echo "правку нужно отправить в обе ветки."
|
||||
echo "Тонкий вызывающий файл исполняется из ветки по умолчанию,"
|
||||
echo "поэтому правку триггеров или прав нужно зеркалить в main (#623)."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
@@ -195,6 +219,7 @@ jobs:
|
||||
PROVENANCE: ${{ steps.provenance.outcome }}
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
ACTION_PINS: ${{ steps.action_pins.outcome }}
|
||||
REVIEWS_INDEX: ${{ steps.reviews_index.outcome }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
@@ -208,10 +233,12 @@ jobs:
|
||||
}
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
check "пины сторонних Actions" "$ACTION_PINS"
|
||||
# skipped — шаг не судил (не push в dev), это не отказ
|
||||
[ "$REVIEWS_INDEX" = "skipped" ] || check "индекс ревью совпадает с каталогом" "$REVIEWS_INDEX"
|
||||
exit $fail
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
@@ -223,7 +250,8 @@ jobs:
|
||||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||||
changes:
|
||||
name: Классификация изменённых файлов
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
|
||||
# содержимого репозитория (#387).
|
||||
permissions:
|
||||
@@ -243,7 +271,7 @@ jobs:
|
||||
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# `base` — до какого коммита классифицировать файлы ветки (#387),
|
||||
# `range_base` — от какого судить диапазон на dev (#388).
|
||||
# `range_base` — от какого судить диапазон на dev/main (#388, #619).
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
@@ -256,7 +284,8 @@ jobs:
|
||||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
|
||||
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
|
||||
# `Release:` вне веток задач, #697), по `workflow_dispatch full=true`, на
|
||||
# PR (#479). Логика и
|
||||
# её тест — scripts/classify-changes.mjs.
|
||||
- id: heavy
|
||||
env:
|
||||
@@ -264,6 +293,7 @@ jobs:
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -283,9 +313,10 @@ jobs:
|
||||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||||
-f branch="$BRANCH" -f status=completed -F per_page=100 \
|
||||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
# На dev классифицировать нечего (всё true), но база диапазона
|
||||
# нужна гейту «новый код не добавляет any» в job frontend (#388).
|
||||
if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then
|
||||
# На интеграционных ветках классифицировать нечего (всё true), но
|
||||
# база диапазона нужна гейту «новый код не добавляет any» в job
|
||||
# frontend (#388, #619).
|
||||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||||
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||||
exit 0
|
||||
@@ -302,8 +333,8 @@ jobs:
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/dev" ]; then
|
||||
echo "dev: без фильтров, всё true"
|
||||
if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then
|
||||
echo "dev/main: без фильтров, всё true"
|
||||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -381,7 +412,8 @@ jobs:
|
||||
# performance_smoke включает набор, иначе glow-only результат засчитался бы
|
||||
# прогону, которому нужен изометрический профиль.
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
@@ -530,7 +562,8 @@ jobs:
|
||||
name: "HACS: валидация репозитория"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: HACS validation
|
||||
@@ -542,7 +575,8 @@ jobs:
|
||||
name: "Hassfest: манифест интеграции"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Hassfest validation
|
||||
@@ -573,7 +607,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -633,29 +667,47 @@ jobs:
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
|
||||
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
|
||||
plan="$RUNNER_TEMP/mutation-plan-$SHARD.txt"
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only > "$plan"
|
||||
cat "$plan"
|
||||
count=$(sed -n 's/^plan=//p' "$plan" | tail -1)
|
||||
echo "к прогону в этом шарде: ${count:-0}"
|
||||
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
|
||||
# #620: план называет и окружение своих гардов. Непрочитанная строка
|
||||
# при непустом плане значит «ставить»: лишняя установка стоит минуты,
|
||||
# недоустановка — красный чистый прогон и лишний круг задачи.
|
||||
if [ "${count:-0}" != "0" ]; then
|
||||
browser=$(sed -n 's/^plan-browser=//p' "$plan" | tail -1)
|
||||
python=$(sed -n 's/^plan-python=//p' "$plan" | tail -1)
|
||||
echo "окружение шарда: браузер ${browser:-true}, Python ${python:-true}"
|
||||
echo "browser=${browser:-true}" >> "$GITHUB_OUTPUT"
|
||||
echo "python=${python:-true}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
run: npm ci
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
# Python и зависимости бэкенда — только шарду с pytest-гардами (#620);
|
||||
# колёса pip кэшируются по requirements.txt.
|
||||
- if: steps.plan.outputs.count != '0' && steps.plan.outputs.python == 'true'
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: pip
|
||||
cache-dependency-path: tests_backend/requirements.txt
|
||||
- name: Backend test dependencies
|
||||
if: steps.plan.outputs.count != '0'
|
||||
if: steps.plan.outputs.count != '0' && steps.plan.outputs.python == 'true'
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
# Chromium — только шарду с браузерными гардами (#620): смоки, golden и
|
||||
# юниты, чей граф исполнения импортирует Playwright.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.plan.outputs.count != '0'
|
||||
if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Затронутые мутанты ловятся
|
||||
if: steps.plan.outputs.count != '0'
|
||||
@@ -684,7 +736,8 @@ jobs:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
# Полная история без блобов (#342): гейт «новый код не добавляет any»
|
||||
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
|
||||
@@ -699,8 +752,10 @@ jobs:
|
||||
run: npm run typecheck
|
||||
# Долг типизации снимается при извлечении подсистем (#34), а не разовой
|
||||
# заменой: в src/** его 1034 вхождения в 49 файлах. Гейт держит приращение
|
||||
# на нуле — судит только добавленные строки (#342).
|
||||
- name: "Новый код не добавляет any"
|
||||
# на нуле — судит только добавленные строки (#342). Тем же диапазоном
|
||||
# судится и приращение записей смоков в приватное состояние карточки
|
||||
# (#629): больше двух тысяч старых не трогаем, новых не добавляем.
|
||||
- name: "Новый код не добавляет any и приватных записей в смоках"
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -713,11 +768,12 @@ jobs:
|
||||
git fetch -q origin dev
|
||||
zero=$(printf '%040d' 0)
|
||||
base=""
|
||||
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
|
||||
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
|
||||
# его прогон штатно отменяется следующим, и добавленные им строки не
|
||||
# судил бы никто. Фолбэк — прежний `before`.
|
||||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||||
# Пуш прямо в dev/main: диапазон — то, что добавлено с последнего
|
||||
# ДОКАЗАННО зелёного предка (#388, #619), а не с головы предыдущего
|
||||
# пуша: его прогон штатно отменяется следующим, и добавленные им
|
||||
# строки не судил бы никто. Фолбэк — прежний `before`.
|
||||
if [ "$EVENT_NAME" != "pull_request" ] \
|
||||
&& { [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; }; then
|
||||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||||
fi
|
||||
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
|
||||
@@ -730,16 +786,31 @@ jobs:
|
||||
echo "не нашёл базу диапазона — гейт пропущен, это увидит следующий пуш"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA"
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
status=0
|
||||
node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA" || status=1
|
||||
node scripts/no-new-private-writes.mjs --base "$base" --head "$HEAD_SHA" || status=1
|
||||
exit "$status"
|
||||
- name: Build
|
||||
run: npm run build
|
||||
# Часть unit-контрактов проверяет размеры реально поставляемых графов в
|
||||
# dist/ (#438, #593, #627). В issue-ветке закоммиченный dist законно
|
||||
# отстаёт от исходников (#657), поэтому юниты обязаны читать свежую
|
||||
# сборку — тот же порядок закреплён локальным gate:small.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
# Entry, manifest and every content-hashed chunk are one artifact (#337).
|
||||
# #657: the committed bundle changes only in a release candidate, so the
|
||||
# fresh build must equal it only there; elsewhere the committed copy
|
||||
# legitimately lags and only the fresh dist is verified.
|
||||
- name: Card bundle trees in sync
|
||||
run: |
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
node scripts/bundle-policy.mjs --verify HEAD
|
||||
npm run bundle:budget
|
||||
# #624: `tsc --noUnusedLocals` чист вне порта/харнесса, шесть чисел
|
||||
# связности монолита не выше scripts/monolith-baseline.json. После
|
||||
# сборки — одно из чисел считает байты dist/.
|
||||
- name: Мёртвый код и связность монолита
|
||||
run: npm run lint:unused
|
||||
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
|
||||
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
|
||||
# --noEmit` плюс rollup, около минуты на job.
|
||||
@@ -765,12 +836,48 @@ jobs:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# #657 (2б): бандл в дереве dev меняет только кандидат беты, поэтому стенд
|
||||
# dev.houseplan.tech берёт его не из дерева, а из этого артефакта: после
|
||||
# зелёного фронтенда на push в dev собранный `dist/` публикуется в служебную
|
||||
# ветку `dev-build` одним коммитом без истории (scripts/dev-build.mjs), хост
|
||||
# забирает его demo/stand/update-dev-bundle.sh. Опубликуется только голова:
|
||||
# если dev ушёл вперёд, пушит следующий прогон. Не вход доказательства:
|
||||
# стенд — не гейт, и сбой публикации не красит проверку кода.
|
||||
dev_build:
|
||||
name: "Бандл головы dev для стенда"
|
||||
needs: frontend
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && needs.frontend.result == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
# download-artifact распаковывает поверх checkout и не удаляет старые
|
||||
# content-hashed чанки закоммиченного release-бандла (#657). Свежий
|
||||
# артефакт должен быть единственным содержимым dist, иначе bundle-tree
|
||||
# справедливо видит оставшиеся файлы как orphan assets.
|
||||
- name: Очистить закоммиченный dist перед артефактом
|
||||
run: |
|
||||
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Опубликовать в dev-build
|
||||
run: node scripts/dev-build.mjs --sha "$GITHUB_SHA" --dist dist
|
||||
|
||||
smoke:
|
||||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||||
@@ -803,6 +910,9 @@ jobs:
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Очистить закоммиченный dist перед артефактом
|
||||
run: |
|
||||
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
@@ -874,7 +984,8 @@ jobs:
|
||||
name: "Смоки: все шарды зелёные"
|
||||
needs: [changes, smoke, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
@@ -898,7 +1009,8 @@ jobs:
|
||||
# inexpensive and catches a different class of regressions than timings.
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
@@ -923,6 +1035,9 @@ jobs:
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Очистить закоммиченный dist перед артефактом
|
||||
run: |
|
||||
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
@@ -997,7 +1112,7 @@ jobs:
|
||||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||||
needs: [changes, frontend, reuse]
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||||
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
|
||||
@@ -1027,6 +1142,9 @@ jobs:
|
||||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Очистить закоммиченный dist перед артефактом
|
||||
run: |
|
||||
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
@@ -1090,7 +1208,8 @@ jobs:
|
||||
name: "Геометрия: TS/Python parity исполнена"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
@@ -1124,7 +1243,8 @@ jobs:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
# Browser fixtures are generated by their real ESM factories and then
|
||||
@@ -1210,7 +1330,8 @@ jobs:
|
||||
name: "Доказательство выполненных проверок"
|
||||
if: always()
|
||||
needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
|
||||
@@ -4,78 +4,49 @@ House Plan is one HACS package with two parts plus a demo harness:
|
||||
|
||||
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to
|
||||
the entry, manifest and hashed chunks under `dist/`.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
|
||||
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home
|
||||
Assistant backend.
|
||||
- **Demo harness** (`demo/`) — a Playwright page (`demo/srv/demo.html`) that
|
||||
renders the card against a fake `hass` for screenshots and the `smoke_*.mjs`
|
||||
suite. The home is fully synthetic. Launcher `demo/serve.mjs`; golden scenes
|
||||
`demo/golden/`, performance `demo/performance/`, guard suite `demo/guard/`,
|
||||
live stand seed `demo/stand/` — each with its own README.
|
||||
|
||||
This file is the map and the few rules every session needs before its first
|
||||
command. `PROCESS.md` is the only complete canon and wins any disagreement;
|
||||
the sections below link to it instead of retelling it.
|
||||
|
||||
## Read this first
|
||||
|
||||
**`docs/SCOPE.md` before anything else.** It was fixed with the owner and states
|
||||
its own authority: features are built, improved and accepted **only** if they
|
||||
serve a job listed there. It carries the mission, the three personas, the core
|
||||
user jobs and the out-of-scope list.
|
||||
serve a job listed there. Its central consequence: **View mode is the product
|
||||
for two of the three personas.** Editors are admin-only tools and must never
|
||||
leak interactions into View. For work that changes visible behaviour, also read
|
||||
`docs/USER-GUIDE.ru.md` — interface wording comes from there and is not invented.
|
||||
|
||||
Its central consequence: **View mode is the product for two of the three
|
||||
personas.** Editors are admin-only tools and must never leak interactions into
|
||||
View.
|
||||
**Reading order by role** (#634). `node scripts/entry-cost.mjs` measures each
|
||||
route and `test/entry-cost.test.mjs` keeps this list equal to its routes:
|
||||
|
||||
For work that changes visible behaviour, also read `docs/USER-GUIDE.ru.md` —
|
||||
interface wording comes from there and is not invented, or the UI starts speaking
|
||||
developer.
|
||||
- author (analysis, spec, implementation, infrastructure): `docs/SCOPE.md` →
|
||||
`AGENTS.md` → `docs/process/AUTHOR.md` → `docs/STATUS.md`;
|
||||
- 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` →
|
||||
`AGENTS.md` → `PROCESS.md` → `docs/STATUS.md`.
|
||||
|
||||
Then `PROCESS.md` (the full process), `docs/STATUS.md` (where the release line
|
||||
is), and for non-trivial changes `docs/ARCHITECTURE.md` plus the canonical
|
||||
document of the subsystem you touch: `SUN.md`, `LIGHT.md`, `CANVAS.md`,
|
||||
`WALL-THICKNESS.md`, `UX-MODES.md`, `CONFIG-COMPATIBILITY.md`,
|
||||
`TOUCH-SUPPORT.md`.
|
||||
The two digests quote and link `PROCESS.md` section by section; open the linked
|
||||
section whenever a digest line governs your current step. For non-trivial
|
||||
changes add `docs/ARCHITECTURE.md` plus the canonical document of the subsystem
|
||||
you touch (one list, the same one the reviewer prompt in `_process.yml` reads):
|
||||
`SUN.md`, `LIGHT.md`, `CANVAS.md`, `WALL-THICKNESS.md`, `UX-MODES.md`,
|
||||
`CONFIG-COMPATIBILITY.md`, `TOUCH-SUPPORT.md`, `ISOMETRIC.md`, `VACUUM.md`,
|
||||
`DECOR-EDITOR.md`, `DEVICE-PRESENTATION.md`, `FILTERING.md`, `STAIRS.md`,
|
||||
`RADAR.md`, `PDF-EXPORT.md`, `STYLING-HOOKS.md`.
|
||||
|
||||
Standard commands live in `package.json` scripts, `CONTRIBUTING.md` and
|
||||
`docs/DEVELOPMENT.md`.
|
||||
|
||||
## Canonical backlog and status
|
||||
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical
|
||||
task records: problem, scope, acceptance criteria and discussion.
|
||||
|
||||
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
|
||||
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
|
||||
of a status and `rejected` on a closed issue. A product issue in flight carries
|
||||
exactly one `S*` label. An infrastructure-only issue is the deliberate exception:
|
||||
it may carry no `S*` label while being implemented, enters the common flow at
|
||||
`S7-code-review`, and from then on carries exactly one. Labels are the whole of
|
||||
it: GitHub Projects is no longer used.
|
||||
|
||||
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
|
||||
issue #338). `small` — the spec lives in the issue body and its review is a
|
||||
comment. Analysis names the `small` criterion the task *fails* when it takes the
|
||||
full track; "ordinary track" without a named criterion is not a justification.
|
||||
The threshold itself did not move — only which side carries the proof. The full
|
||||
track stays what it was for geometry, config migrations and public contracts,
|
||||
where a criterion is broken plainly and saying which one is easy.
|
||||
|
||||
`trivial` — the short track: no spec stage at all, `S2-analysis` straight to
|
||||
`S5-ready`, with the AC written into the issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track: it checks scope, risks and the evidence
|
||||
from executed tests, but does not stand in for executing them.
|
||||
`PROCESS.md` §5 and §5.1 hold the criteria.
|
||||
|
||||
An issue filed by an outsider is worked exactly like one of the owner's own, once
|
||||
the owner has decided to take it. For product work the check sits **at the
|
||||
entrance**, not on every step: the first status label admits it to the flow. For
|
||||
infrastructure work an explicit assignment by the owner is the entrance, and the
|
||||
issue may remain without an `S*` label until its first `S7-code-review`. In either
|
||||
case, **who filed it stops mattering** once the owner has admitted it.
|
||||
|
||||
Applying that first label *is* the owner's explicit decision, and the platform
|
||||
already guarantees it — only someone with write access can label. The earlier rule
|
||||
made outside reports be refiled as the owner's own issues, which turned out to be
|
||||
work for nothing: on #123 the spec was already written by the time the guard
|
||||
refused.
|
||||
|
||||
Specs, audits and ADRs may live under `docs/`, but must link to their issue and
|
||||
must not become a parallel task list. When repository documentation disagrees with
|
||||
Issues, the issue wins.
|
||||
Where the rest lives: commands — `package.json` scripts, `CONTRIBUTING.md`,
|
||||
`docs/DEVELOPMENT.md` (toolchain, build, release — its Release section is the
|
||||
only home of release mechanics); tests and gates — `docs/TESTING.md`.
|
||||
|
||||
## Rule #1
|
||||
|
||||
@@ -92,170 +63,106 @@ The label must be one of `S5-ready`, `S6-in-progress`, `S7-code-review`. Anythin
|
||||
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.
|
||||
|
||||
## Change classes
|
||||
GitHub Issues are the canonical task records and the **labels** are the status
|
||||
(`PROCESS.md` §9); when repository documentation disagrees with an issue, the
|
||||
issue wins. Change classes (`PROCESS.md` §1): **A** product (`src/**`,
|
||||
integration Python, manifests, i18n), **B** gates and tooling (tests, `demo/**`,
|
||||
`scripts/**`, `.github/**`, build and package configuration), **C**
|
||||
documentation, **D** generated (bundle, golden baselines) — D beats A where paths
|
||||
overlap. The committed bundle changes only in a commit with a `Release:` trailer
|
||||
(#657); an ordinary task restores it with `npm run bundle:clean` before
|
||||
committing.
|
||||
|
||||
| Class | Paths | Issue required |
|
||||
|---|---|---|
|
||||
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
|
||||
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
|
||||
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build the complete tree with `npm run bundle:sync` |
|
||||
**Tracks** (`PROCESS.md` §5, §5.1): the label `track:ship`, `track:show` or
|
||||
`track:ask` sets the route, and the owner's label beats the criteria. `show` is the
|
||||
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.
|
||||
`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
|
||||
the branch does not merge cleanly into `dev`. Review checks scope, risks and the
|
||||
evidence from executed tests, but does not replace executing them.
|
||||
|
||||
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
|
||||
configuration files this one omits — `package.json`, `package-lock.json`,
|
||||
`pytest.ini`, `.gitignore`, `.gitattributes`, `.githooks/**` and the rest of
|
||||
`.github/**` are class B. Where paths overlap, **D beats A**: the built bundle
|
||||
lives inside `custom_components/houseplan/frontend/` and would otherwise read as
|
||||
product source.
|
||||
## Specs
|
||||
|
||||
## Commits
|
||||
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
|
||||
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
|
||||
and takes no new files. Required sections and the rule for questions are
|
||||
`PROCESS.md` §7.1: only **product** ambiguity goes to the owner — what a person
|
||||
sees or does, and how much user-visible change belongs in this issue — in one
|
||||
batched comment with a proposed default for each question and `blocked` on top of
|
||||
`S3-spec`. Everything a user cannot observe is yours to decide and record.
|
||||
|
||||
Hooks install themselves: `package.json` runs `"prepare": "node
|
||||
scripts/install-hooks.mjs"`, so `npm ci` sets `core.hooksPath` in every fresh
|
||||
clone. Verify with `git config core.hooksPath` — expect `.githooks`.
|
||||
## Commits and branches
|
||||
|
||||
Every non-merge commit carries **terminal** trailers:
|
||||
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:
|
||||
|
||||
```text
|
||||
Issue: #123
|
||||
User-Visible: yes
|
||||
```
|
||||
|
||||
One `Issue:` line per issue if a commit closes several. `User-Visible: no` for
|
||||
tests, refactors, tooling and documentation that does not change the product.
|
||||
`User-Visible: yes` requires edits to **both** changelogs — `docs/CHANGELOG.md`
|
||||
and `docs/CHANGELOG.ru.md` — in the same commit.
|
||||
One `Issue:` line per issue. `User-Visible: yes` requires edits to **both**
|
||||
`docs/CHANGELOG.md` and `docs/CHANGELOG.ru.md` in the same commit. A commit
|
||||
touching `demo/golden/baselines/**` also needs `Release:` plus exactly one of
|
||||
`Baseline-Reviewed: <GitHub run URL>` or `Baseline-Reviewed-Local: sha256:<WSL
|
||||
attestation>` (`PROCESS.md` §10.1). Never invent a review link and never rewrite
|
||||
published history to satisfy trailers.
|
||||
|
||||
A commit touching `demo/golden/baselines/**` additionally requires:
|
||||
Branch `issue/<NN>-slug`; direct commits to `dev`, no PR (owner's decision); a
|
||||
violation is fixed with a follow-up commit, never a force-push.
|
||||
|
||||
```text
|
||||
Release: v1.62.0-beta.9
|
||||
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/<run-id>
|
||||
```
|
||||
|
||||
Never invent a review link and never rewrite published history to satisfy
|
||||
trailers. `.githooks/commit-msg` and the `provenance` CI job both run
|
||||
`scripts/validate-commit-provenance.mjs`.
|
||||
|
||||
Branch: `issue/<NN>-slug`. Direct commits to `dev`, no PR — the owner's decision;
|
||||
CI checks after the fact, and a violation is fixed with a follow-up commit, never
|
||||
a force-push.
|
||||
|
||||
**Push after every task, not before a beta.** While work sits unpushed there is
|
||||
nothing to review, and reviewing twenty tasks at once is not review. `dev` may hold
|
||||
unreviewed code while a task is in flight; what matters is its state when the
|
||||
reviewer says it is accepted.
|
||||
|
||||
**Standing permission: push `issue/<NN>-slug` without asking.** The reviewer runs
|
||||
in CI and can only read what is on the remote — an unpushed spec or commit means
|
||||
the review either stalls or judges the wrong tree. Pushing a task branch publishes
|
||||
nothing to users and does not touch the integration branch, so it needs no command.
|
||||
|
||||
**Do not merge into `dev` by hand.** On a green code review the pipeline rebases
|
||||
the task branch onto `dev`, pushes it, and only then sets `S8-merged` — the label
|
||||
asserts the code is in `dev`, so the merge has to happen first or the label lies
|
||||
in between.
|
||||
|
||||
If the rebase conflicts the pipeline says so in the issue and sends the task back
|
||||
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
|
||||
remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
|
||||
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
|
||||
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
|
||||
the result into your last commit; a conflict anywhere else aborts and leaves the
|
||||
tree as it was. Then push the branch and re-apply `S7-code-review`. When the
|
||||
only difference from the reviewed material is the pipeline's own review-document
|
||||
commit, the next run re-applies the green verdict without calling the model
|
||||
(#499); any other change to the tree — a rebase included — gets a full review.
|
||||
|
||||
The pipeline is an idempotent controller (#499): only `S4-spec-review` and
|
||||
`S7-code-review` start it, it reads the issue's *current* labels rather than the
|
||||
event snapshot, and a label removed before the run starts is treated as a
|
||||
withdrawn request. Push the material **before** applying the label — the reviewer
|
||||
is pinned to the SHA the pipeline captured and must not fetch newer commits. The second review run is not a formality — after a rebase onto a
|
||||
moved `dev` this is different code, and accepting it unchecked is how regressions
|
||||
arrive. Cycles are counted per stage, so a code review spends its own budget.
|
||||
|
||||
Everything else still requires the owner's explicit command: pushing `main`,
|
||||
creating tags, publishing betas and releases, closing issues.
|
||||
- **Push after every task, not before a beta** — while work sits unpushed there
|
||||
is nothing to review.
|
||||
- **Standing permission: push `issue/<NN>-slug` without asking.** The reviewer
|
||||
runs in CI and reads only the remote; a task branch publishes nothing to users.
|
||||
Push the material **before** applying the review label.
|
||||
- **Do not merge into `dev` by hand.** On a green review the pipeline rebases,
|
||||
pushes and only then sets `S8-merged`. A conflicting rebase returns the task to
|
||||
`S6-in-progress` with the verdict intact; `node scripts/rebase-on-dev.mjs`
|
||||
resolves conflicts in generated files only (`PROCESS.md` §2.10), then push and
|
||||
re-apply `S7-code-review`.
|
||||
- Everything else needs the owner's explicit command: pushing `main`, tags,
|
||||
publishing betas and releases, closing issues.
|
||||
|
||||
## Working trees (#115)
|
||||
|
||||
One checkout, one `HEAD`: two agents sharing a directory inherit each other's
|
||||
branch, and twice in one hour a commit landed on someone else's task branch that
|
||||
way. The layout is therefore fixed:
|
||||
branch. `houseplan-card-src/houseplan-card` is the author's tree — task branches
|
||||
live there, and unfamiliar local changes belong to the author or the owner,
|
||||
never reset or clean them away. `houseplan-card-src/hp-dev` is the owner's
|
||||
worktree, permanently on `dev`. The reviewer owns no tree: it runs in CI on a
|
||||
fresh checkout. A worktree works only on the machine that created it — its
|
||||
`.git` file records an absolute path in that machine's format.
|
||||
|
||||
- **`houseplan-card-src/houseplan-card`** — the author's tree. Task branches live
|
||||
here; nobody else commits in it. Unfamiliar local changes belong to the author
|
||||
or the owner — never reset or clean them away.
|
||||
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
|
||||
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
|
||||
- **The reviewer owns no author tree.** It runs in CI on a fresh checkout. The
|
||||
agent implementing an infrastructure task is an ordinary task author and uses
|
||||
the same author-tree rules as product work; task branches must not share a
|
||||
mutable checkout concurrently.
|
||||
## Handoff and the verdict
|
||||
|
||||
A worktree is only usable on the machine that created it: the `.git` file records
|
||||
an absolute path in that machine's format. One created from a Linux sandbox is
|
||||
dead on Windows and vice versa — create worktrees on the machine that will use
|
||||
them, which for `hp-dev` means the owner's.
|
||||
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
|
||||
`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,
|
||||
verdict — are `PROCESS.md` §7.2.
|
||||
|
||||
## Agent-neutral workflow
|
||||
**One handoff, one push** (`PROCESS.md` §10.4). Run
|
||||
`node scripts/process-gate.mjs --issues` before pushing; after
|
||||
`S7-code-review` do not push to the branch until the verdict or the return
|
||||
arrives — a push on top of a running review cancels it.
|
||||
|
||||
No task type is reserved for Codex, Claude or any other named model. **Any agent
|
||||
may take any task**: analysis, spec, product implementation, infrastructure or a
|
||||
release explicitly commanded by the owner. Roles describe the current artifact,
|
||||
not the agent brand. The owner rules on product disputes, closes issues and
|
||||
commands releases.
|
||||
|
||||
Author and reviewer are independent agents/sessions. They need not use different
|
||||
model families, but the reviewer must start without implementation context and
|
||||
must not be the author grading their own work. The reviewer does not edit the
|
||||
material under review.
|
||||
|
||||
**Infrastructure-only work uses an accelerated entry into the common flow.** It
|
||||
is implemented immediately by any agent, without analysis, spec, spec review or
|
||||
the statuses `S1`…`S6`. Once the branch is ready and pushed, apply
|
||||
`S7-code-review`. From there the ordinary controller applies: green review rebases
|
||||
and merges the checked material into `dev` and then sets `S8-merged`; findings or
|
||||
a failed merge return the issue to `S6-in-progress`, and after correction it is
|
||||
submitted to `S7-code-review` again.
|
||||
|
||||
The test for "infrastructure only" is mechanical: **not a single class A file** —
|
||||
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
|
||||
task that touches class A even once is not infrastructure and takes the full flow;
|
||||
there is no such thing as "mostly infrastructure". The strictness is deliberate:
|
||||
a loose reading would turn this into the route by which product changes skip
|
||||
review.
|
||||
|
||||
What stays mandatory either way: an issue exists, both trailers are on every
|
||||
commit, proportionate local gates are green (normally `typecheck`, `test` and
|
||||
`build`), and any non-obvious decision is written down in the code or the issue
|
||||
rather than kept in someone's head. Infrastructure work skips specification, not
|
||||
code review.
|
||||
|
||||
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
|
||||
pipeline. Deterministic gates, model review and integration have independent
|
||||
55/45/55-minute budgets (#551); typical runs finish well before those ceilings.
|
||||
|
||||
**Having applied one of those labels, wait for the result instead of ending the
|
||||
session.** Reporting "handed over for review" stops a conveyor that could have kept
|
||||
moving on its own. An agent has no clock — it exists only during its own turn — so
|
||||
waiting means polling: every 90 seconds, at most 110 times. A single long sleep hits
|
||||
the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue NN
|
||||
[--sha <tip>]` (#496): it watches the label, the pipeline's own comments (conflict,
|
||||
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
|
||||
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
|
||||
4 on timeout — the same 90 s × 110 without a model turn per tick. It writes
|
||||
nothing. Pipeline comments older than the latest application of `S4`/`S7` are
|
||||
the baseline, not an outcome of the new round; an outcome from the current round
|
||||
which already exists when the waiter starts is still delivered immediately (#546).
|
||||
Watch the **label**, not the comment: the label is the state,
|
||||
the comment only explains it. Do not wait at all while `blocked` is set — the task
|
||||
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
|
||||
tell the owner: a failed run leaves the label where it was, forever.
|
||||
|
||||
What the new label means:
|
||||
**Having applied `S4-spec-review` or `S7-code-review`, wait for the result
|
||||
instead of ending the session.** The label starts the pipeline by itself. Poll
|
||||
with `node scripts/wait-verdict.mjs --issue NN [--sha <tip>]` (#496): it watches
|
||||
the label and the pipeline's comments every 90 s, at most 110 times, prints only
|
||||
on a change and exits 0 on a new label, 3 on an event that needs a hand, 4 on
|
||||
timeout. Watch the **label**, not the comment. Do not wait while `blocked` is
|
||||
set.
|
||||
|
||||
| Now reads | What happened | What you do |
|
||||
|---|---|---|
|
||||
@@ -266,309 +173,21 @@ What the new label means:
|
||||
| `review-4` | the cycle limit is spent | stop, the owner decides |
|
||||
|
||||
**After a review run the label always changes.** If it did not, the run itself
|
||||
failed rather than the work — say so to the owner instead of polling on.
|
||||
failed rather than the work — say so to the owner instead of polling on. The
|
||||
bounded queue reconciler (#555) re-wakes a review whose event was lost; it is a
|
||||
safety net, not permission to stop waiting for the review you started.
|
||||
|
||||
The repository also has a bounded queue reconciler (#555). It takes one S4/S7
|
||||
snapshot every thirty minutes and exits. It may re-apply the same review label
|
||||
only when the matching event was lost or its run ended with a transient
|
||||
cancellation/timeout before a sealed result existed. It never applies verdicts or
|
||||
merges. Running work, `blocked`, `review-4`, a foreign material/stage/attempt, a
|
||||
guard failure, or an unintegrated sealed model result is left untouched and gets
|
||||
at most one machine-keyed diagnostic. This is a safety net, not permission for an
|
||||
agent to stop waiting for the result of the review it started.
|
||||
|
||||
**A failed pre-release gate does not send the issue back to review.** The
|
||||
implementation loop runs only typecheck, unit and build; golden, browser smokes,
|
||||
performance and the full HA harness run before a beta, which is after the code
|
||||
review has passed and the issue sits in `S8-merged`. Some defects cannot surface
|
||||
any earlier.
|
||||
|
||||
Fix it, re-run what failed, and a green run is enough for the release to continue.
|
||||
The issue stays in `S8-merged`. Record the **exact command and its result** in the
|
||||
issue — "verified" without a command proves nothing. Trailers as usual, and
|
||||
`User-Visible: yes` still means both changelogs in the same commit.
|
||||
|
||||
The exception covers repairing the defect the gate named, not carrying on
|
||||
development under the name of a repair. It goes through the normal flow — a new
|
||||
issue, or back to `S6-in-progress` — if the fix changes a behaviour contract, gives
|
||||
the user something new, reaches a subsystem the task never touched, or is
|
||||
comparable in size to the task itself. And editing the gate so it stops failing is
|
||||
concealment, not repair; the exception is a defect proven to be **in the fixture**,
|
||||
as on #89, where the sun sat at azimuth 180° and the only window faced north, so no
|
||||
ray was ever built.
|
||||
|
||||
Baselines are still accepted only via `npm run golden:accept -- --reviewed` on a
|
||||
complete Linux CI artefact. "So the gate goes green" is not a reason.
|
||||
|
||||
The exchange happens in **issue comments** — there is no local message bus. Verdict
|
||||
format:
|
||||
|
||||
```text
|
||||
Verdict: green/yellow/red · cycle r<N>/4 · High: N · Medium: N → in-task | #… · Document: …
|
||||
```
|
||||
|
||||
High blocks. A Medium finding INSIDE the task's scope is fixed within the task:
|
||||
with no High findings the verdict is yellow, the author fixes it and the fix
|
||||
passes another review cycle — no separate issue (owner's decision 2026-08-19,
|
||||
#202: filing and servicing an issue costs far more than fixing in place). Only
|
||||
a Medium finding OUTSIDE the scope becomes its own issue — foreign scope is
|
||||
never patched from this branch. Low is fixed or waived with a note
|
||||
in the review document. A yellow verdict is legitimate even when every acceptance
|
||||
criterion passes, if the change does not solve the stated scenario or degrades a
|
||||
neighbouring one.
|
||||
|
||||
**Four review cycles** (two on the light track). The counter lives in the document
|
||||
name, `-r1`…`-r4`; the fourth adds the `review-4` label. There is no fifth attempt:
|
||||
the owner splits the task, rejects it, or arbitrates.
|
||||
|
||||
On the light track (`small`: complexity ≤3, one surface, no config migration, no
|
||||
new UX contract, no perf or touch impact — all at once) the spec lives in the issue
|
||||
body and the spec review is a comment. Code review is never skipped. This track is
|
||||
the default: taking the full one means naming the criterion above that the task
|
||||
does not meet.
|
||||
|
||||
## Specs
|
||||
|
||||
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
|
||||
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
|
||||
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
|
||||
product ones: which persona meets this, on which surface, at what moment; and what
|
||||
the person sees before and after, in one sentence without implementation terms.
|
||||
Proof that a verdict was passed on a given text is the pipeline's job: it writes
|
||||
the `sha256` of the normalised body into the review document's anchor block, and
|
||||
an edit made after a green spec review reaches the code reviewer as a finding.
|
||||
|
||||
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
|
||||
fact is the worst kind of defect: it passes review because it looks like a decision.
|
||||
|
||||
The owner answers exactly two kinds of question: **what a person sees or does**, and
|
||||
**how much user-visible change belongs in this issue**. Behaviour in a boundary case,
|
||||
which persona wins when two conflict, what counts as acceptable degradation, whether
|
||||
a neighbouring behaviour is in scope here or becomes its own issue.
|
||||
|
||||
Everything a user cannot observe is yours to settle: where state is stored, which
|
||||
module carries the guard, naming, file layout, test strategy, migration mechanics,
|
||||
development policy. Decide it, record it in an explicit "assumed, change freely"
|
||||
block, and let the reviewer challenge it. A technical disagreement between author and
|
||||
reviewer is settled by the verdict, not by the owner; it reaches him only when the
|
||||
cycle limit is exhausted.
|
||||
|
||||
Split a mixed question instead of escalating all of it. "Where does this state live"
|
||||
is technical. "Does it survive a page reload and follow the plan across screens" is
|
||||
product. Ask the second, decide the first.
|
||||
|
||||
Ask in one batched issue comment, each question carrying a proposed default, and put
|
||||
`blocked` on top of `S3-spec` while waiting. A question with a default costs the
|
||||
owner seconds; one without costs him minutes.
|
||||
|
||||
## Gates
|
||||
|
||||
```
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build
|
||||
npm run inventory # the only correct way to get test counts
|
||||
```
|
||||
|
||||
Never copy test counts into documents by hand; they go stale in days.
|
||||
|
||||
After building, keep the complete manifest-driven bundle trees in sync — CI
|
||||
verifies every listed file byte-for-byte:
|
||||
|
||||
```
|
||||
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479,
|
||||
#576): build with typecheck, `no-new-any` and `smoke-select` start in parallel;
|
||||
unit tests follow the completed build because their bundle-contract witnesses
|
||||
read the freshly produced `dist`, then the bundle-tree comparison and the
|
||||
bundle budget run. It prints the smokes the
|
||||
diff selects but does not run them by default — `npm run gate:small -- --smokes`
|
||||
(#496) adds the browser phase after the artefact preparation: `bundle-sync`, then
|
||||
the directly matched and registered smokes two at a time; "broad" matches stay
|
||||
the reviewer's call. `golden`, `pytest` and `check-docs --screenshots=strict`
|
||||
remain the author's call by diff and AC.
|
||||
|
||||
**Start a task from its packet** (#496): `node scripts/task-packet.mjs --issue NN`
|
||||
prints one derived view — status and track, what the status permits, the owner's
|
||||
recent decisions, the branch against `dev` and Validate on its tip, the previous
|
||||
verdict with its recorded tree, AC → evidence from the last review document and
|
||||
what is still unwitnessed. It reads GitHub and git and writes nothing; the labels
|
||||
remain the only source of status.
|
||||
|
||||
**Heavy CI gates run on the beta candidate, nightly and on demand — not on every
|
||||
push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
|
||||
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
|
||||
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
|
||||
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
|
||||
units, build, bundle sync, no-new-any), the narrow TS/Python geometry parity
|
||||
guard when its inputs changed, backend, hacs and hassfest. Screenshot
|
||||
freshness in `check-docs` is likewise a warning on a plain push and an error on
|
||||
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
|
||||
without the `Release:` trailer, so a green Validate without the heavy jobs can
|
||||
never pass for a release.
|
||||
|
||||
During the implementation cycle the fast gates always run. Since 2026-08-14 the
|
||||
owner's machine also carries Playwright with Chromium (Windows) and a full WSL
|
||||
environment, which changes one thing (#151): **before moving an issue to
|
||||
`S7-code-review`, run the smokes named in its AC locally** — `node
|
||||
demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
|
||||
locally with `gh` available before pushing (without `gh` the hook cannot check the
|
||||
issue status and stays silent). After `S7-code-review` do not push to the branch
|
||||
until the verdict or the return arrives: a push on top of a running review cancels
|
||||
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
|
||||
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
|
||||
with the diff mutants on the material itself and returns a red one to `S6` without
|
||||
spending a review cycle. Mutants by diff run only where they are explicitly
|
||||
requested — the review candidate, the merge candidate (both dispatch Validate
|
||||
with `mutants=true`) and PRs (#510, #601). Ordinary pushes, the beta candidate
|
||||
(`Release:` trailer) and `full=true` do not request them: a routine push costs
|
||||
~3 minutes (08–09.09 mutants cost 48 of 56 Validate job-hours and were mostly
|
||||
cancelled by the next push), and by the beta every issue has already been
|
||||
mutated twice — on review and on the rebased merge candidate; the night runs
|
||||
the full registry (`mutation-gate.yml`), not a diff subset.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
the beta gate is CI at the exact SHA.
|
||||
|
||||
**Verifying and capturing are different things (#455).** `golden:verify` is
|
||||
advisory and legal anywhere, Windows included: it reports differences and
|
||||
accepts nothing. **Capturing** frames is refused outside Linux
|
||||
before the browser even starts — `golden:capture` through
|
||||
`demo/golden/policy.mjs`, documentation screenshots through
|
||||
`npm run docs:capture` (use that script, not a bare `node
|
||||
demo/docs/capture.mjs`: the gate sits one step earlier because editing the
|
||||
capture script invalidates the committed screenshot index). The refusal prints
|
||||
the WSL command. The reason is not policy but physics: Windows
|
||||
rasterizes text through DirectWrite with different subpixel and DPI behaviour,
|
||||
so no frame ever matches an accepted baseline byte for byte, no environment
|
||||
witness can exist, and acceptance would refuse anyway (#401 accepts any
|
||||
environment that proves itself with byte-identical undeclared frames — Linux is
|
||||
simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build. The one local
|
||||
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
|
||||
machine, compares decoded pixels with the committed frames and, only when every
|
||||
frame is identical, refreshes the manifest fingerprints — frames that differ go
|
||||
through the artefact as before. The displayed card version reaches the DOM
|
||||
through `displayVersion()` (`src/card-version.ts`); the global
|
||||
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
|
||||
sets it.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without
|
||||
Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when
|
||||
`homeassistant` is not importable), so a green result proves nothing. Say so in the
|
||||
report instead of claiming the backend was verified. Cloud agents have the harness
|
||||
at `.venv-backend/bin/python`.
|
||||
|
||||
**Running the app / smoke suite**: build a fresh bundle and copy it into the demo
|
||||
assets first, then run `node demo/smoke_*.mjs`. No real Home Assistant server is
|
||||
required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
|
||||
|
||||
**Golden images**: `npm run golden:capture` and `npm run golden:verify` refuse a
|
||||
stale demo bundle. Build and copy first, then review `artifacts/golden/actual/` and
|
||||
`diff/`. Update baselines only with `npm run golden:accept -- --reviewed`, using the
|
||||
complete Linux CI artifact; never accept a partial scenario or images merely to make
|
||||
CI green. See `demo/golden/README.md`.
|
||||
|
||||
**Freshness contract**: the embedded fingerprint covers `src/` plus Rollup,
|
||||
TypeScript and package-lock build inputs. Every browser check must verify it
|
||||
before trusting a result — benchmarks, golden runs and documentation captures
|
||||
call `assertFreshDemoBundle` themselves, and smokes get it from `launch()` in
|
||||
`demo/serve.mjs` (#236). A missing or mismatched fingerprint is a hard failure,
|
||||
not a warning; `HP_ALLOW_STALE_BUNDLE=1` skips the check for debugging and says
|
||||
so out loud. A smoke against a stale bundle does not fail cleanly: part of its
|
||||
assertions go red and part stay green, which reads as a logic defect.
|
||||
|
||||
**CI is pinned to an exact SHA.** The release gate accepts only a `completed
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `geometry_parity`, `backend`.
|
||||
The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge. A stable release additionally waits for Full
|
||||
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
|
||||
dispatched on the candidate SHA by `release.yml`, #514/#540); betas and the
|
||||
development cycle never run E2E. Stable installable assets (`houseplan.zip`,
|
||||
`houseplan-card.js` and their `SHA256SUMS`) reach the public stable release only
|
||||
from `release.yml` after those gates; a release published by hand is turned back
|
||||
into a draft first (#540). The prerelease publisher additionally ships a
|
||||
candidate-bound `RELEASE-MEMBERSHIP.json` covered by the same passport (#547).
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
A failed pre-release gate (golden, full smokes, performance, HA harness) does not
|
||||
send the issue back to review: fix, re-run what failed, record the exact command
|
||||
and result in the issue (`PROCESS.md` §11.4 — and its limits).
|
||||
|
||||
## Environments
|
||||
|
||||
**Local Windows checkout** is the day-to-day environment: repository-pinned Node
|
||||
and Python as in CI (`npm run toolchain:check` compares the machine with the pins
|
||||
CI actually uses — `.nvmrc` and `.python-version` are derived from the same
|
||||
sources, #496),
|
||||
`gh` authenticated. On the owner's machine do not trust the ambient PATH:
|
||||
`.\scripts\windows-toolchain.ps1 setup|check` owns a verified portable Node and
|
||||
dedicated `.venv-ci`, and its `npm`/`node`/`python`/`playwright` actions are the
|
||||
explicit pinned entrypoints (#557). It changes no persistent PATH and never
|
||||
deletes a mismatched venv. In WSL, work from an ext4 clone and use
|
||||
`bash scripts/wsl-setup.sh --verify` for the real HA subset plus one Linux visual
|
||||
capture; exact-SHA Linux CI remains authoritative. `.venv-backend` does **not**
|
||||
exist there — it is provisioned only by cloud agent startup scripts, which also
|
||||
run `npm ci` and install Playwright Chromium.
|
||||
|
||||
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
|
||||
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
|
||||
Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
|
||||
reproduces against the pristine committed bundle, so treat it as
|
||||
pre-existing/pixel-precision, not a regression you introduced.
|
||||
|
||||
## Alpha experiments
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. One browser-local switch controls the complete set known to the
|
||||
installed build: `hp_alpha=1` in query or the shared hash grammar enables and
|
||||
persists it, while `hp_alpha=0` disables and persists it. Do not add per-feature
|
||||
URL/storage keys or a YAML/config switch. The legacy `hp-labs` grammar and
|
||||
`houseplan_card_labs_v1` storage are ignored and never migrated.
|
||||
|
||||
A new registry entry needs a unique lowercase id, issue, summary and
|
||||
unit/browser coverage; it does not get `since` or `expires`. Invalid or duplicate
|
||||
entries fail closed. Alpha capabilities may alter presentation only and must not
|
||||
gate data, migrations, stores, HA actions or network calls. Current renderer
|
||||
details are in `docs/ISOMETRIC.md`.
|
||||
|
||||
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
|
||||
page directly in a browser renders the floor plan but **device icons only appear
|
||||
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
|
||||
smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does
|
||||
not. This is a harness limitation, not a card bug.
|
||||
|
||||
## Promotion rule
|
||||
|
||||
Every new feature or material behaviour change must be published as a beta/RC
|
||||
before it can enter a stable release, even when its local audit is clean. The
|
||||
stable release commit is promotion-only: version fields, generated bundle
|
||||
snapshots and changelog/release metadata. Do not add feature source code in
|
||||
that commit. An explicit owner-requested emergency hotfix is the only exception
|
||||
and must be called out in the release handoff.
|
||||
|
||||
A `Release vX.Y.Z-beta.N candidate` commit is **not** promotion-only: it carries
|
||||
the work itself and follows the ordinary rules, trailers included.
|
||||
|
||||
Issues are closed in a batch when a beta ships, not when implementation ends: that
|
||||
way a bug found in the beta returns to the same task, and the beta announcement can
|
||||
list what went in. The batch is immutable candidate membership proven by Git
|
||||
trailers, never the mutable S8 queue at close time; authorship does not change
|
||||
membership. Status labels are stripped as the issues close, and retries resume the
|
||||
same manifest without duplicating the release comment (#547).
|
||||
The owner's Windows machine: `.\scripts\windows-toolchain.ps1 setup|check` owns
|
||||
the pinned Node and Python; WSL works from an ext4 clone with
|
||||
`bash scripts/wsl-setup.sh --verify` (`docs/DEVELOPMENT.md` › Local Windows
|
||||
workstation). `npm run toolchain:check` compares any machine with the pins CI
|
||||
uses. The full Home Assistant harness cannot run on native Windows; without an
|
||||
importable `homeassistant` pytest does not collect `test_ha_*.py` at all, so a
|
||||
green pure run proves nothing about the harness (`docs/TESTING.md`). Cloud
|
||||
agents have the harness at `.venv-backend/bin/python`.
|
||||
|
||||
+46
-18
@@ -38,6 +38,17 @@ an eager import. Extend the runtime, manifest, file/key/placeholder parity and
|
||||
regional-locale tests together; never bypass the registry by importing a locale
|
||||
directly in a component.
|
||||
|
||||
Administrator-only copy lives in three lazy namespace dictionaries —
|
||||
`src/i18n/settings/`, `src/i18n/support/` and `src/i18n/topology/` (#627). Their
|
||||
English file is static (the synchronous fallback); every other language is one
|
||||
lazy chunk per namespace × language: a two-line loader module
|
||||
`src/i18n/<namespace>/<namespace>-<code>.ts`, one `import()` with a
|
||||
content-hashed retry token in `src/i18n/<namespace>.ts`, and one entry in
|
||||
`NAMESPACE_LOCALE_CHUNKS` in `scripts/bundle-manifest.mjs`. The lazy surfaces
|
||||
(onboarding, editor runtime, Zigbee overlay) wait for their dictionaries before
|
||||
painting; never import a non-English namespace JSON statically — the budget
|
||||
gate and `test/i18n-lazy-namespaces.test.mjs` refuse it.
|
||||
|
||||
The current `subst()` helper does not implement plural rules. Phrase strings so
|
||||
their grammar does not depend on the numeric value (for example, use a neutral
|
||||
label followed by `{n}` rather than an English singular/plural pair).
|
||||
@@ -47,6 +58,22 @@ set; maintain the existing English and Russian documentation according to the
|
||||
project's normal rules. Right-to-left layout is a separate product project,
|
||||
because the plan canvas and editors cannot be mirrored by translations alone.
|
||||
|
||||
## Documentation screenshots
|
||||
|
||||
The images under `docs/images/` are produced only from synthetic data on the
|
||||
pinned Chromium in CI. A task branch does not commit them, nor the golden
|
||||
baselines: once per beta the `beta-derived.yml` workflow refreshes the
|
||||
fingerprint, the frames and the golden baselines on `dev` in one bot commit,
|
||||
accepting only the frames it was told to expect (`PROCESS.md` §8, #697). A task
|
||||
that changes visuals on purpose sets the `ci:golden` label. The manual path —
|
||||
the `Docs screenshots` workflow and `npm run docs:accept -- --reviewed
|
||||
--from=<unpacked artifact>`, or `--identical` when no pixel can move — stays
|
||||
for the release manager. Scenario version, source fingerprint and every image hash
|
||||
are recorded in the [screenshot index](docs/images/screenshots.json), and
|
||||
`node scripts/check-docs.mjs` reports a stale fingerprint: a warning on an
|
||||
ordinary push, an error on a beta candidate (a commit with a `Release:`
|
||||
trailer). The full rule is in `PROCESS.md` §8.
|
||||
|
||||
## Where to ask
|
||||
|
||||
Not sure whether something is a bug, or just want to discuss an idea before
|
||||
@@ -77,25 +104,25 @@ npm install # also installs .githooks through the prepare script
|
||||
|
||||
### Why `--filter=blob:none` (#345)
|
||||
|
||||
A full clone is **215 MB of `.git`**; a blobless one is **26 MB** — measured, not
|
||||
estimated. Both carry all 1611 commits and all 182 tags, so ranges, `merge-base`
|
||||
and `git diff` across history work identically; `git diff origin/dev~3..origin/dev`
|
||||
in a blobless clone takes about a second and grows `.git` by one megabyte.
|
||||
|
||||
The difference is that historical *file contents* are fetched only if something
|
||||
actually asks for them. That matters here because 32% of the pack is documentation
|
||||
screenshots — ten PNGs re-captured 196 times — and another sizeable share is the
|
||||
committed bundle, one 1.16 MB file per product change. Almost nobody ever reads an
|
||||
old revision of either.
|
||||
A blobless clone keeps every commit and tag, so ranges, `merge-base` and
|
||||
`git diff` across history work exactly as in a full clone; only historical *file
|
||||
contents* are fetched on demand. Most of the pack is exactly such content that
|
||||
almost nobody reads again — documentation screenshots re-captured with the UI and
|
||||
the committed bundle rewritten by every release candidate — so a blobless clone
|
||||
is several times smaller. To see the numbers for your own clone, compare
|
||||
`git count-objects -vH` in a blobless and in a full one.
|
||||
|
||||
Drop the flag if you work offline with history, or need `git log -p` over the whole
|
||||
tree repeatedly. Do **not** replace it with `--depth=1`: a shallow clone is about
|
||||
the same size but has no `merge-base`, so the process gate, `smoke-select` and
|
||||
every `origin/dev..HEAD` range stop working.
|
||||
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need Python ≥3.13 and
|
||||
`pytest-homeassistant-custom-component home-assistant-frontend`; CI runs them on
|
||||
every push — locally they are skipped when `homeassistant` is not importable.
|
||||
The HA-harness backend tests (`tests_backend/test_ha_*.py`) need the
|
||||
repository-pinned Python (`.python-version`) and
|
||||
`pytest-homeassistant-custom-component home-assistant-frontend`; their canon is
|
||||
Linux CI or WSL (`bash scripts/wsl-setup.sh --verify`). Without an importable
|
||||
`homeassistant` they are not collected at all — not skipped — and pytest prints
|
||||
`HA harness NOT collected: …` (#630).
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -103,8 +130,10 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
- Every UI string goes through `src/i18n/<lang>.json`; follow the
|
||||
[Translations](#translations) flow for registry and backend parity.
|
||||
- The built card must be committed in sync: `cp dist/houseplan-card.js
|
||||
custom_components/houseplan/frontend/` (CI compares them byte-for-byte).
|
||||
- The committed bundle changes only in a release candidate: `npm run
|
||||
bundle:release` in a commit with a `Release:` trailer (#657). An ordinary task
|
||||
restores it with `npm run bundle:clean` before committing — the `commit-msg`
|
||||
hook refuses a bundle change otherwise.
|
||||
- Tap actions have a security model (locks/alarms never toggle from the plan) —
|
||||
see `resolveToggleIntent` in `src/device-toggle.ts`; don't weaken it.
|
||||
- Every commit follows the issue and trailer contract in `PROCESS.md`.
|
||||
@@ -114,6 +143,5 @@ every push — locally they are skipped when `homeassistant` is not importable.
|
||||
## Architecture
|
||||
|
||||
Start with `docs/ARCHITECTURE.md` (data model, WS API, coordinate system) and
|
||||
`docs/STATUS.md` (current state). Release: bump the version in `package.json`,
|
||||
`manifest.json`, `const.py`, `CARD_VERSION`, tag `vX.Y.Z`, publish a GitHub release —
|
||||
the workflow attaches the card bundle.
|
||||
`docs/STATUS.md` (current state). Release mechanics live in one place:
|
||||
`docs/DEVELOPMENT.md` › Release.
|
||||
|
||||
+427
-216
@@ -3,7 +3,7 @@
|
||||
> **Статус документа: канон** (редакция 2026-08-13, ролевое уточнение
|
||||
> 2026-09-13). Решения владельца, на
|
||||
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
|
||||
> имена английские · лёгкий трек **включён** · автор и ревьюер — независимые
|
||||
> имена английские · треки `ship`/`show`/`ask` задаёт метка владельца (#695) · автор и ревьюер — независимые
|
||||
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
|
||||
> входят в общий флоу сразу на `S7-code-review`.
|
||||
>
|
||||
@@ -12,6 +12,14 @@
|
||||
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
> **Ролевые конспекты** (#634): `docs/process/AUTHOR.md` и
|
||||
> `docs/process/REVIEWER.md` — выжимки этого файла со ссылками на его разделы.
|
||||
> Автор и ревьюер входят через них (порядок чтения по роли — `AGENTS.md`) и
|
||||
> открывают раздел канона, когда пункт конспекта касается текущего шага.
|
||||
> Правил конспекты не добавляют; при расхождении побеждает этот файл. Ссылки и
|
||||
> ключевые формулировки конспектов сверяет `test/process-digests.test.mjs`,
|
||||
> поэтому правка формулировки здесь правит и конспект тем же коммитом.
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
|
||||
@@ -42,7 +50,7 @@
|
||||
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
|
||||
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
|
||||
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
|
||||
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
|
||||
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью. **Бандл** (`dist/**`, `custom_components/houseplan/frontend/**`) с #657 меняет только коммит с трейлером `Release:` — кандидат беты или релиза (`npm run bundle:release`); в обычной задаче закоммиченный бандл законно отстаёт от исходников, сборка в коммит не идёт (`npm run bundle:clean`). Судит `validate-commit-provenance.mjs` — хук `commit-msg` и история в CI |
|
||||
|
||||
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
|
||||
бандл» перестают быть лазейками.
|
||||
@@ -85,8 +93,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на треке `show` 2
|
||||
трек `show` (§5) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
трек `ship` (§5) идёт S1-new → S5-ready, минуя S2, S3 и S4
|
||||
инфраструктурный трек (§1): без S → S7-code-review ⟲ S6-in-progress → S8-merged
|
||||
```
|
||||
|
||||
@@ -117,9 +126,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
|
||||
называется критерий §5, который она не проходит: «обычный трек» без
|
||||
названного критерия обоснованием не является.
|
||||
8. трек — метка `track:ship`, `track:show` или `track:ask` (§5), **по
|
||||
умолчанию `track:show`**. Для `track:ask` называется критерий §5, которого
|
||||
задача не проходит; `track:ship` предлагается, когда правка описывается
|
||||
одним предложением. Метка владельца главнее предложения аналитика.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
@@ -131,7 +141,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
- **Выход:** на `track:ask` — `S3-spec`, на `track:show` — `S5-ready` после AC в
|
||||
теле issue (§5); переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
@@ -151,7 +162,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
зелёный / жёлтый / красный. Ревью ТЗ проходит только трек `ask` (§5).
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
@@ -166,7 +177,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
|
||||
Все пункты обязательны:
|
||||
|
||||
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
|
||||
- ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте.
|
||||
На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом
|
||||
«нет»: критерии трека их исключают, иначе это `track:ask`;
|
||||
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
|
||||
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
|
||||
- перечислены затронутые файлы и модули;
|
||||
@@ -207,9 +220,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
|
||||
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
|
||||
Попутных правок «раз уж я здесь» не бывает.
|
||||
- **Документация — в том же коммите,** что и поведение (действующая политика
|
||||
`docs/STATUS.md`): changelog RU+EN для пользовательского, `STATUS.md` для
|
||||
состояния, `DEVELOPMENT.md` для новых грабель, `ARCHITECTURE.md` для дизайна.
|
||||
- **Документация — в том же коммите,** что и поведение: changelog RU+EN для
|
||||
пользовательского, `STATUS.md` для состояния, `DEVELOPMENT.md` для новых
|
||||
грабель, `ARCHITECTURE.md` для дизайна.
|
||||
- **Выход:** локальный гейт зелёный (§8), хендофф-комментарий (§7.2).
|
||||
|
||||
### 2.7 Код-ревью
|
||||
@@ -243,6 +256,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
|
||||
Новый мутант с 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-обоснования; ревьюер проверяет
|
||||
не только наличие строки, но и невозможность более дешёвого `node --test`.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов
|
||||
принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся
|
||||
@@ -256,13 +278,38 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
видит разницу.
|
||||
- **High блокируют.** Medium **в скоупе задачи** чинится в текущем issue:
|
||||
без High это жёлтый вердикт и возврат автору, фикс проходит повторный цикл.
|
||||
Medium **вне скоупа** — отдельный issue (#202).
|
||||
Medium **вне скоупа** — отдельный issue (#202). Жёлтый вердикт законен и
|
||||
тогда, когда все AC выполнены, если изменение не решает заявленный сценарий
|
||||
или ухудшает соседний.
|
||||
- **Вердикт привязан к SHA (#312).** Все числа и факты отчёта сверяются с
|
||||
`git rev-parse HEAD` непосредственно перед подведением итогов, а не с SHA,
|
||||
зафиксированным в начале разбора: во время ревью в ветку может прилететь
|
||||
fix-up. Серверный стопор — шаг слияния конвейера сверяет вершину ветки с
|
||||
SHA материала ревью (допустим только собственный doc-коммит публикации
|
||||
поверх) и при расхождении отменяет слияние с возвратом в `S6-in-progress`.
|
||||
- **Контракты по монолиту — исполнением, не regex по тексту (#624).**
|
||||
Новое утверждение о `src/houseplan-card.ts` или
|
||||
`src/houseplan-editor-runtime.ts` доказывается экспортом функции и её
|
||||
вызовом в `test-build`, а не поиском строки в исходнике: текстовый якорь
|
||||
краснеет на переносе метода без единой регрессии, и это делает вынос дороже,
|
||||
чем оставить монолит как есть. Список тестов, читающих монолит как текст,
|
||||
заморожен (`FROZEN_TEXT_ANCHOR_TESTS` в `test/monolith-text-anchors.test.mjs`)
|
||||
и может только уменьшаться; новое имя в нём — находка ревью, а не запись в
|
||||
список. Связность монолита измеряется шестью числами
|
||||
(`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`); рост — только с записью в
|
||||
issue задачи и правкой базы в том же коммите.
|
||||
- **Смок входит в сценарий через публичную поверхность (#629).** DOM с
|
||||
контрактными хуками, события HA и фикстуры, тестовый фасад `window.__hpTest`
|
||||
(`docs/TESTING.md`, «Тестовый фасад и приватное состояние»). Приватное поле
|
||||
карточки — только для чтения в ассертах. Новая запись в него без
|
||||
`// private-ok: <конкретная причина>` — находка ревью, даже если гейт
|
||||
`no-new-private-writes` её не увидел (мутация через вызов, запись через
|
||||
псевдоним).
|
||||
- **Выход:** очередь на пре-релиз либо возврат в «В разработке», не более
|
||||
4 циклов (§4). Второй и последующие циклы разбираются по дельте (§2.10).
|
||||
|
||||
@@ -318,10 +365,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
Находкой остаётся другое: **SHA, мёртвый уже в момент публикации отчёта** —
|
||||
он означает, что значение сняли до `amend` или `rebase` и не сверили перед
|
||||
выводом, как требует §7.2. Это отличие не теоретическое: на #403 оба
|
||||
источника, автор и ревьюер, независимо назвали один и тот же осиротевший
|
||||
SHA, и следующий раунд восстанавливал коммит по содержимому диффа руками
|
||||
(issue #413). Конвейер теперь такую публикацию останавливает сам;
|
||||
выводом, как требует §2.7 («Вердикт привязан к SHA»). Это отличие не
|
||||
теоретическое: на #403 оба источника, автор и ревьюер, независимо назвали один
|
||||
и тот же осиротевший SHA, и следующий раунд восстанавливал коммит по
|
||||
содержимому диффа руками (issue #413). Конвейер теперь такую публикацию останавливает сам;
|
||||
3. по каждой находке предыдущего раунда показать, **чем именно она закрыта** —
|
||||
строкой кода или текста, а не заявлением автора;
|
||||
4. заново проверять только те AC, чьё доказательство дельта задевает;
|
||||
@@ -329,11 +376,54 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
проверки, со ссылкой на документ того раунда и его материал. Без перечня
|
||||
сокращение превращается в молчаливое доверие.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
|
||||
**Индекс документов ревью** — `docs/reviews/INDEX.md` (#635): одна строка на
|
||||
документ — issue, этап, раунд, вердикт, число High/Medium, заголовки находок,
|
||||
файлы из находок (искать по имени файла: `grep form-kit docs/reviews/INDEX.md`).
|
||||
Файл генерируется `node scripts/reviews-index.mjs` и пересобирается **только
|
||||
коммитами, идущими в `dev`** (#657, решение 1б): слиянием кандидата —
|
||||
после ребейза, а если `dev` не двигался, то поверх материала перед
|
||||
fast-forward (`--commit-if-stale`, коммит класса C) — и публикацией документа ревью ТЗ
|
||||
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
|
||||
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
|
||||
конфликтуют на нём по построению. Руками не правится. Конфликт ребейза, в котором **все** пути —
|
||||
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
|
||||
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
|
||||
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Шаг Validate
|
||||
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
|
||||
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
|
||||
Правка `docs/reviews/`
|
||||
руками — перенос в `legacy/`, удаление — сопровождается
|
||||
`node scripts/reviews-index.mjs` в том же коммите. Прежде чем брать
|
||||
задачу по подсистеме, стоит прочитать её строки в индексе: что находили и чем
|
||||
закрывали — там, а не в тысяче файлов. Уроки, пережившие свою задачу,
|
||||
собираются в `docs/LESSONS.md` с датой и ссылкой на источник.
|
||||
|
||||
**Хранение документов** (решение владельца 23.09, #635): в `docs/reviews/`
|
||||
лежат все раунды всех задач текущей линии — предыдущие раунды нужны ссылкам
|
||||
«Унаследовано из r<N−1>» и якорям материала. При стабильном релизе документы
|
||||
задач, вошедших в него, переносятся в `legacy/reviews/<vX.Y.Z>/` одним
|
||||
коммитом класса C; индекс пересобирается и перечисляет только живые. Перенос —
|
||||
часть чеклиста стабильного релиза, не отдельная задача, и делает его
|
||||
`node scripts/reviews-archive.mjs --through=vX.Y.Z` (без `--apply` — только
|
||||
план, #682). Членство — трейлеры `Issue: #NN` в диапазоне линии, как у
|
||||
манифеста беты и ревью линии (§11.5); метка не доказательство. Уточнения:
|
||||
задача с трейлером и после тега остаётся в `docs/reviews/` целиком — её раунды
|
||||
ещё продолжаются; задача с трейлерами в двух выпущенных линиях уезжает целиком
|
||||
в последнюю; закрытая без выпуска (как #522) уезжает с линией, где лёг её
|
||||
документ — коммит документа несёт трейлер; `RELEASE-REVIEW-vX.Y.Z.md` уходит в
|
||||
каталог своего тега, поэтому перенос делается после публикации ревью линии.
|
||||
Относительные ссылки в перенесённых документах и в соседях, которые на них
|
||||
ссылаются, инструмент переписывает сам; `--check-links` печатает оставшиеся
|
||||
битые.
|
||||
Перенос идёт при пустой очереди `S7-code-review`: ребейз чужой ветки иначе
|
||||
упрётся в перемещённый каталог.
|
||||
|
||||
Дешёвые гейты (`typecheck`, `test`, `build` с проверкой целостности сборки, `bundle-policy --verify`) гоняются в
|
||||
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
|
||||
|
||||
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
|
||||
`dev` (после ребейза это другой код, §7.2), смена контракта поведения, задета
|
||||
`dev` (после ребейза это другой код, §10.4), смена контракта поведения, задета
|
||||
новая подсистема, либо объём дельты сопоставим с исходной задачей.
|
||||
|
||||
Сокращается объём **разбора, а не строгость**: правка по замечанию способна
|
||||
@@ -376,9 +466,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
принятие эталонов со ссылкой на прогон CI.
|
||||
принятие эталонов с доказательством ревью: URL Linux CI run либо хеш
|
||||
аттестованного WSL-артефакта.
|
||||
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
|
||||
полному Linux-артефакту. Принятие ради зелёного CI — нарушение процесса.
|
||||
полному Linux-артефакту: либо GitHub CI, либо `npm run golden:wsl:capture` в
|
||||
WSL/ext4 с clean опубликованным SHA и машинно-проверяемым паспортом. Второй
|
||||
путь убирает только первый ожидаемо красный CI-прогон; полный GitHub Validate
|
||||
на точном SHA коммита с эталонами остаётся обязательным. Принятие ради
|
||||
зелёного CI — нарушение процесса. Где принимаются эталоны — §8: на `dev`
|
||||
одним коммитом на бету, в задаче — только с меткой `ci:golden` (#697).
|
||||
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
|
||||
раньше, не «по факту наличия кода», не исполнителем.
|
||||
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
|
||||
@@ -426,90 +522,90 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
|
||||
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
|
||||
заведением issue вместо возврата.
|
||||
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
|
||||
переписывают трижды, лёгкой не была.
|
||||
- На треке `show` лимит код-ревью — **2** цикла, ревью ТЗ на нём нет (§5):
|
||||
задача на три часа, которую переписывают трижды, лёгкой не была.
|
||||
|
||||
---
|
||||
|
||||
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
|
||||
## 5. Треки `ship`, `show`, `ask` — метка владельца
|
||||
|
||||
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
|
||||
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
|
||||
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
|
||||
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
|
||||
отдельного цикла.
|
||||
Решение владельца 2026-09-28, issue #695. Разбор 85 закрытых задач #600–#691
|
||||
показал, что прежний лёгкий трек (`small`) стоил почти столько же, сколько
|
||||
полный: медиана 115 мин и 12 событий против 102 мин и 13. Дешевле был только
|
||||
короткий (`trivial`) — 41 мин и 5 событий. Трек определялся формальными
|
||||
критериями, и у владельца не было метки, чтобы задать его самому.
|
||||
|
||||
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
|
||||
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
|
||||
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
|
||||
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
|
||||
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
|
||||
нарушенный несложно.
|
||||
**Трек задаёт метка `track:ship`, `track:show` или `track:ask`. Метка владельца
|
||||
главнее критериев:** критерии ниже — подсказка аналитика, а не приговор.
|
||||
|
||||
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
|
||||
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
|
||||
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
|
||||
- Аналитик предлагает трек в «Оценке» (§7.2) и ставит метку. По умолчанию —
|
||||
`track:show`.
|
||||
- Владелец ставит или меняет любую из трёх меток в любой момент; его метка
|
||||
окончательна.
|
||||
- Повысить трек (`ship` → `show` → `ask`) вправе любой агент, с причиной в
|
||||
комментарии. Понизить — только владелец.
|
||||
- Трек пересматривается, когда владелец снял усложнявший пункт (#688):
|
||||
аналитик предлагает понижение, решает владелец.
|
||||
|
||||
**Критерии — все одновременно; нарушенный называется явно:**
|
||||
| | `track:ship` | `track:show` | `track:ask` |
|
||||
|---|---|---|---|
|
||||
| Для чего | документация, текст, очевидная правка в несколько строк, CSS-мелочь | баг и полировка в рамках описанного поведения; инфраструктура (§1) по умолчанию | геометрия, миграции конфига, публичные контракты, перф и touch, новый UX-контракт |
|
||||
| Маршрут | `S1-new` → `S5-ready` → `S6` → `S7` → `S8` | `S1-new` → `S2-analysis` → `S5-ready` → `S6` → `S7` → `S8` | полный, §2 |
|
||||
| ТЗ | строка «что меняется и чем проверить» в теле issue под `## ТЗ` | «Оценка» (§7.2) и до трёх AC в теле issue под `## ТЗ` | полное ТЗ по §7.1 |
|
||||
| Ревью ТЗ | нет | нет | да, лимит 4 цикла |
|
||||
| Локальный гейт | `npm run gate:small` | `npm run gate:small` плюс смоуки `smoke-select` и AC | §8 |
|
||||
| Код-ревью | до слияния нет; пакетное ревью диапазона перед бетой | модель: корректность и AC | документ ревью, как в §2.7 |
|
||||
| Лимит циклов код-ревью | — | 2 | 4 |
|
||||
|
||||
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
|
||||
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
|
||||
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
|
||||
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
|
||||
диапазона перед бетой (§11.7).
|
||||
|
||||
**Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу:
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
- одна поверхность (один диалог, один модуль, один эндпоинт);
|
||||
- нет миграции конфига и новых compatibility-полей;
|
||||
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
|
||||
- нет влияния на производительность и на touch-контракт.
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
|
||||
(в теле issue, как и на полном треке с 2026-09-10);
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
|
||||
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается:
|
||||
оно проверяет скоуп, риски и качество доказательств, но не заменяет исполнение
|
||||
тестов. Единственное исключение из повторного ревью — починка упавшего
|
||||
предрелизного гейта, §11.4.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
полное ТЗ в теле issue по §7.1. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
|
||||
обходится без него совсем.
|
||||
|
||||
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
|
||||
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
|
||||
|
||||
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
|
||||
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
|
||||
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
|
||||
|
||||
**Критерии, все обязательны:**
|
||||
|
||||
- тип `bug`;
|
||||
- правка ограничена одной поверхностью, нового UX-контракта нет;
|
||||
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
|
||||
- AC выражаются тремя проверяемыми утверждениями или меньше;
|
||||
- нет влияния на производительность и на touch-контракт;
|
||||
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
|
||||
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
|
||||
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
|
||||
|
||||
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
|
||||
где владелец утверждает и приоритет.
|
||||
Невыполненный пункт — повод предложить `track:ask` с названным критерием.
|
||||
`track:ship` — когда правку можно описать одним предложением и она укладывается
|
||||
в рамки выше.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
|
||||
Лимит циклов код-ревью — 2, как на лёгком треке.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
|
||||
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
|
||||
**Чем опасны `show` и `ship`.** Они убирают место, где решение проверялось до
|
||||
написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение. Если по
|
||||
ходу выясняется, что решать есть что, трек повышается до `ask`: issue уходит в
|
||||
`S3-spec` и получает полное ТЗ в теле issue по §7.1. Это не провал, а ранняя
|
||||
диагностика.
|
||||
|
||||
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
|
||||
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
|
||||
**Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры
|
||||
коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега
|
||||
беты (§8), гейты стабильного релиза. Качество держится на `dev` и на кандидате
|
||||
беты, а не на каждой ветке.
|
||||
|
||||
### 5.1 Метки тяжёлых проверок и прежние метки
|
||||
|
||||
Тяжёлые проверки заказываются метками на любом треке — ставит владелец или
|
||||
автор с причиной в комментарии:
|
||||
|
||||
| Метка | Что включает |
|
||||
|---|---|
|
||||
| `ci:full` | полный Validate на ветке задачи |
|
||||
| `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче |
|
||||
| `ci:mutants` | мутанты по диффу на кандидате ревью и слияния |
|
||||
|
||||
Как конвейер читает метки, описывает §10.4: `ci:mutants` (#696), `ci:full` и
|
||||
`ci:golden` — dispatch Validate с `full=true` на материале ревью (#697).
|
||||
|
||||
**Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая
|
||||
задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без
|
||||
трековой метки — как `track:show`. Новые задачи получают только `track:*`.
|
||||
|
||||
---
|
||||
|
||||
@@ -553,7 +649,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (только `track:ask`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
|
||||
@@ -605,7 +701,7 @@ issue #NN
|
||||
Короткие и однообразные, чтобы читались и человеком, и машиной.
|
||||
|
||||
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
@@ -623,16 +719,6 @@ issue #NN
|
||||
прогон показал, почему это неверно: жёлтый там означал, что AC описывает неверное
|
||||
изменение контракта — реализовать такое ТЗ значило бы сделать ошибку по инструкции.
|
||||
|
||||
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
|
||||
|
||||
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
|
||||
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
|
||||
вместе с самой таблицей. Статус задачи — только метка `S*`.
|
||||
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
|
||||
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
|
||||
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
|
||||
ревью кладёт в `docs/reviews/` сам конвейер, в ветку задачи.
|
||||
|
||||
---
|
||||
|
||||
## 8. Гейты
|
||||
@@ -643,13 +729,13 @@ issue #NN
|
||||
```
|
||||
npx tsc --noEmit
|
||||
npm test
|
||||
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
|
||||
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
|
||||
npm run build && node scripts/bundle-policy.mjs --verify HEAD
|
||||
# сборка цела; копии сверяются только на кандидате (#657).
|
||||
# Копия стенда — `npm run bundle:sync` (#255); перед коммитом `npm run bundle:clean`
|
||||
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
|
||||
node demo/smoke_<целевые>.mjs
|
||||
node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый код не добавляет any
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
npm run golden:verify # только с меткой ci:golden (#697)
|
||||
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
|
||||
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд
|
||||
npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
@@ -657,10 +743,11 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
# если менялось одно из зеркал junction limits
|
||||
```
|
||||
|
||||
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
|
||||
`any` в 49 файлах; перетипизировать это одним заходом — месяц риска ради нуля
|
||||
пользовательской ценности, поэтому долг снимается при плановом извлечении
|
||||
подсистем (#425, прежний #34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
|
||||
**Новый код не добавляет `any`** (#342). Явного `any` в `src/**` — сотни
|
||||
вхождений (`node scripts/no-new-any.mjs --total`); перетипизировать это одним
|
||||
заходом — месяц риска ради нуля пользовательской ценности, поэтому долг
|
||||
снимается при плановом извлечении подсистем (#425, прежний #34), а не разовой
|
||||
заменой. Гейт `scripts/no-new-any.mjs` судит
|
||||
**только добавленные строки**: существующий долг на нетронутой строке законен,
|
||||
правка строки со старым `any` — новая ответственность. Исключение объявляется на
|
||||
той же строке, `// any-ok: <конкретная причина>`; голый маркер и причины вида
|
||||
@@ -668,24 +755,25 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
комментарии, строке или идентификаторе ложных срабатываний не даёт.
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
|
||||
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
|
||||
`typecheck`, `npm test`, `npm run build` с `bundle-policy --verify` (копии сверяются на кандидате, #657).
|
||||
Свежесть скриншотов документации — не гейт задачи (#697, ниже). По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их —
|
||||
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
|
||||
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
|
||||
диффу, печатает
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
|
||||
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
|
||||
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml`
|
||||
(съёмка и приёмка одним коммитом бота на `dev`, #697) или `Docs screenshots`
|
||||
(`workflow_dispatch`, только артефакт) с приёмкой вручную: `npm run docs:accept --
|
||||
--reviewed --from=<распакованный артефакт>` (#246). Ручной путь остаётся
|
||||
релиз-менеджеру, если бот недоступен. Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
|
||||
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота
|
||||
проверяет релиз-менеджер, коммит ручной приёмки делает человек.
|
||||
|
||||
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
|
||||
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
|
||||
@@ -694,13 +782,32 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
|
||||
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
|
||||
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
|
||||
при следующей задаче (#237). Пересъёмка — `npm run build && node
|
||||
demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
**Производные артефакты — на `dev`, один коммит на бету** (#697, решение
|
||||
владельца 2026-09-28). Отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим. Пока его
|
||||
коммитила каждая задача, `docs/images/screenshots.json` правили 70 раз за 14 дней,
|
||||
и две параллельные задачи конфликтовали на нём гарантированно. Golden
|
||||
оплачивала следующая задача: сдвиг, влитый одной, всплывал у другой (#687 →
|
||||
#685, #688 → #689). Теперь:
|
||||
|
||||
- **ветки задач не коммитят** `docs/images/**` и `demo/golden/baselines/**`. На
|
||||
ветке свежесть скриншотов — предупреждение preflight, а golden не идёт;
|
||||
- **перед кандидатом беты** `beta-derived.yml` одним коммитом бота обновляет на
|
||||
`dev` отпечаток и кадры (съёмка тем же каноном, приёмка `docs:accept
|
||||
--reviewed`) и эталоны golden из артефакта полного Validate на `dev`
|
||||
(`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите).
|
||||
Изменившийся кадр или сцена принимается, только если назван во входах
|
||||
workflow; необъявленная разница — отказ с перечнем. Коммит проверяет
|
||||
релиз-менеджер;
|
||||
- **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер
|
||||
прогоняет полный набор на материале ревью, и сдвинутые кадры задача
|
||||
принимает сама — по §3 п.13;
|
||||
- **`Release:` на ветке задачи тяжёлый набор не включает** (`classify-changes.mjs`):
|
||||
тяжёлое запускают `ci:full` и `ci:golden`, трейлер `Release:` на `dev` и ночной
|
||||
прогон.
|
||||
|
||||
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
|
||||
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
@@ -768,8 +875,10 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Модификаторы: трек `track:ship`/`track:show`/`track:ask` (§5), тяжёлые проверки
|
||||
`ci:full`/`ci:golden`/`ci:mutants` (§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`)
|
||||
ортогональны процессу.
|
||||
|
||||
@@ -821,7 +930,10 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
|
||||
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
|
||||
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс `Baseline-Reviewed:`.
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
|
||||
`Baseline-Reviewed: <URL GitHub run>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
|
||||
совпадать с `localAttestation.sha256` в принятом индексе.
|
||||
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
|
||||
job `provenance` в `validate.yml`.
|
||||
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
|
||||
@@ -866,8 +978,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
|
||||
заморожен (#517);
|
||||
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
|
||||
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>`;
|
||||
5. коммит только класса D невалиден без `Release: vX.Y.Z`,
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`;
|
||||
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
|
||||
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
|
||||
|
||||
@@ -929,7 +1042,7 @@ SHA. Manifest публикуется и входит в `SHA256SUMS`; свежа
|
||||
|
||||
### 10.4 Событийный конвейер: метка как триггер
|
||||
|
||||
`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в
|
||||
`.github/workflows/process.yml` (тело — `_process.yml`, #623), issue #114. Смена статусной метки — не запись в
|
||||
журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг.
|
||||
|
||||
```
|
||||
@@ -939,8 +1052,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
|
||||
Текущая техническая реализация независимого ревьюера —
|
||||
`anthropics/claude-code-action`; это деталь автоматизации, а не закрепление роли
|
||||
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`, этот
|
||||
документ и тело issue, публикует разбор комментарием, заводит issue на
|
||||
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
конспект `docs/process/REVIEWER.md` с разделами этого документа по его ссылкам
|
||||
(#634) и тело issue, публикует разбор комментарием, заводит issue на
|
||||
Medium-находки вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки
|
||||
задачи и возвращает вердикт структурированным JSON. **Метку переставляет отдельная
|
||||
детерминированная стадия по вердикту, а не модель.**
|
||||
@@ -950,16 +1064,76 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает
|
||||
события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после
|
||||
первого шага без ошибок в логах;
|
||||
2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues`
|
||||
GitHub берёт workflow только оттуда, независимо от содержимого `dev`;
|
||||
2. для события `issues` GitHub берёт workflow только из **ветки по умолчанию**
|
||||
(`main`), независимо от содержимого `dev`. Поэтому в `main` лежит тонкий
|
||||
`process.yml` — триггер, run-name, потолок прав, — а тело `_process.yml` он
|
||||
вызывает по ссылке `@dev` (#623). Правило ниже, «Workflow из ветки по
|
||||
умолчанию», — общее для всех таких файлов;
|
||||
3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в
|
||||
промежутке — она утверждает, что код в `dev`;
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
|
||||
**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623). Для
|
||||
событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из
|
||||
`main`. Таких файлов шесть: `process.yml`, `process-resume.yml`,
|
||||
`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`,
|
||||
`process-metrics.yml`. Каждый — тонкий вызывающий: триггеры, run-name,
|
||||
права, concurrency и одна job `uses:
|
||||
Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`.
|
||||
Тело `_<имя>.yml` читается из `dev` в момент запуска, поэтому **правка
|
||||
конвейера — один коммит в `dev`**, зеркало в `main` и возврат `main` в `dev`
|
||||
перед промоушеном не нужны. Потолок прав вызывающей job равен объединению
|
||||
прав job тела: вызываемый workflow права только сужает, и каждая job тела
|
||||
получает прежний минимум (#556). Тонкий файл меняется, только когда меняются
|
||||
триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в
|
||||
`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными —
|
||||
сверяются ровно эти шесть файлов, список держит
|
||||
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
|
||||
по расписанию он судит `main` собственным телом из `main`.
|
||||
|
||||
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
|
||||
снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и
|
||||
диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1:
|
||||
инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`.
|
||||
|
||||
| | `ship` | `show` | `ask` |
|
||||
|---|---|---|---|
|
||||
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | с мутантами по диффу, dispatch |
|
||||
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
|
||||
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
|
||||
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | dispatch с мутантами |
|
||||
|
||||
Метка `ci:mutants` возвращает мутанты по диффу на любом треке. Рамки `ship`
|
||||
(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена
|
||||
`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue
|
||||
комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя
|
||||
не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно
|
||||
применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
|
||||
|
||||
В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью
|
||||
кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там.
|
||||
Ориентир — данные #695: `small` стоил медиану 115 минут и 12 событий, `trivial` —
|
||||
41 минуту и 5 событий; `show` целится в уровень `trivial`.
|
||||
|
||||
**Ревью `show` судит корректность и AC.** Medium — дефект поведения, который
|
||||
увидит пользователь, или невыполненный AC. Бухгалтерия — нет мутанта или записи
|
||||
в реестре, нечувствительный тест на побочный вызов, формулировка в документе —
|
||||
Low и цикла не открывает. Отсутствие мутантов по диффу на `show` не находка:
|
||||
полный реестр гоняется ночью. Ревью `show` не ставит Chromium, если тело issue
|
||||
не называет смоук или браузер. Ревью ТЗ не ставит ни `npm ci`, ни браузер: кода
|
||||
оно не исполняет.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
|
||||
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
|
||||
(#636): подготовка убеждается, что dispatch встал на материал, кладёт запечатанный
|
||||
маркер ожидания `review-pending-…` и завершается; по завершении Validate
|
||||
`process-resume.yml` (`workflow_run`) переставляет метку `S7-code-review`, и новый
|
||||
прогон конвейера находит завершённый dispatch сразу. Страховка на потерянное
|
||||
событие — `process-reconcile.yml`: успешный прогон подготовки с маркером и уже
|
||||
завершённым Validate он будит повторной меткой, без маркера — как прежде, только
|
||||
диагностика. Будить без маркера нельзя: это второй вызов модели. Красный или
|
||||
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
|
||||
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
|
||||
только по явному запросу: на кандидате ревью, кандидате слияния (#492) — оба
|
||||
@@ -970,14 +1144,15 @@ Medium-находки вне скоупа задачи (#202), кладёт до
|
||||
мутантов не запрашивают** (#601, решение владельца 20.09): мутационный гейт
|
||||
проверяет тесты, а не продукт (#513), к бете каждая задача прогнана им дважды —
|
||||
на ревью и на слитом после ребейза кандидате, — а ночью идёт полный реестр
|
||||
(`mutation-gate.yml`, 01:00 UTC). Релизный гейт (#541) требует полного Validate,
|
||||
(`mutation-gate.yml`, 00:43 UTC). Релизный гейт (#541) требует полного Validate,
|
||||
но не mutant-jobs; для ревью и слияния шесть исполненных mutant-jobs остаются
|
||||
обязательными.
|
||||
|
||||
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
|
||||
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
|
||||
съедает время модели, а ожидание кандидата после зелёного вердикта не обрывает
|
||||
готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
|
||||
съедает время модели (с #636 — и не занимает раннер: до этого подготовка спала
|
||||
≈ 28 минут на раунд при 10–12 минутах работы модели), а ожидание кандидата после
|
||||
зелёного вердикта не обрывает готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
|
||||
этап, раунд, branch, SHA/tree материала, якоря ТЗ и результат Validate. Получатель
|
||||
сверяет полный набор файлов, SHA-256 и все поля с outputs предыдущей стадии;
|
||||
неполный, чужой или устаревший результат fail-closed не публикуется и не разрешает
|
||||
@@ -1022,7 +1197,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257) — на треке `ask`. `show`
|
||||
и `ship` с чистым слиянием ребейзятся один раз, при слиянии (#696, выше). Раньше ревью читало ветку
|
||||
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
|
||||
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
|
||||
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
|
||||
@@ -1032,7 +1208,7 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
- ветка уже содержит весь `dev` — ничего;
|
||||
- отстала и ребейзится чисто — ребейз, `push --force-with-lease`, ревью по
|
||||
приведённому состоянию. Факт ребейза передаётся в промпт, чтобы сработало
|
||||
правило §7.2 о полном разборе вместо дельты;
|
||||
правило §2.10 о полном разборе вместо дельты;
|
||||
- конфликт — возврат в `S6-in-progress` **до** запуска ревью. Цикл при этом не
|
||||
расходуется: код никто не читал, вердикта нет.
|
||||
|
||||
@@ -1054,10 +1230,12 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
|
||||
к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
|
||||
вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и
|
||||
лёгкий Validate кандидата уже запустил сам push в ветку: слияние ждёт этот
|
||||
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
|
||||
@@ -1104,7 +1282,7 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
повторно **без вызова модели**, если последний документ этапа несёт записанный
|
||||
конвейером вердикт `green` с High 0 и дерево материала не изменилось ни в одном
|
||||
файле вне `docs/reviews/**` (сравнивает `git diff` по содержимому). Ребейз, правка
|
||||
теста, фикстуры или ТЗ дают отличие дерева и полный разбор — правило §7.2 не
|
||||
теста, фикстуры или ТЗ дают отличие дерева и полный разбор — правило §2.10 не
|
||||
ослабляется, оно просто не касается дерева, которое уже читали.
|
||||
|
||||
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
|
||||
@@ -1114,9 +1292,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
|
||||
## 11. Исключения
|
||||
|
||||
### 11.1 Лёгкий трек
|
||||
### 11.1 Треки
|
||||
|
||||
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
|
||||
См. §5 — треки `ship` и `show` не исключения из правила №1, а более дешёвые пути
|
||||
по тем же статусам.
|
||||
|
||||
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
|
||||
|
||||
@@ -1125,7 +1304,7 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
- issue создан в **той же сессии до коммита**, метка `hotfix`;
|
||||
- ТЗ «как сделано» + раздел «почему нельзя было ждать»;
|
||||
- в течение 24 часов задача ретроспективно проходит код-ревью;
|
||||
- аварийность названа явно в релизном хендоффе (действующее правило `AGENTS.md`).
|
||||
- аварийность названа явно в релизном хендоффе.
|
||||
|
||||
### 11.3 Гигиена репозитория
|
||||
|
||||
@@ -1157,7 +1336,12 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- трейлеры на коммите как обычно, `Issue: #NN` того же issue;
|
||||
- при `User-Visible: yes` — правки в оба changelog в том же коммите;
|
||||
- эталоны golden принимаются только через `npm run golden:accept -- --reviewed`
|
||||
на полном артефакте Linux CI. «Чтобы гейт позеленел» основанием не является.
|
||||
на полном Linux-артефакте GitHub CI либо полном аттестованном WSL-артефакте;
|
||||
после локальной приёмки полный GitHub Validate на точном финальном SHA всё
|
||||
равно обязателен. «Чтобы гейт позеленел» основанием не является. Сдвиг, который
|
||||
влили задачи линии, принимается на `dev` одним коммитом на бету —
|
||||
`beta-derived.yml` с объявленными сценами (§8, #697), а не задачей, которая
|
||||
наткнулась на него следующей.
|
||||
|
||||
**Границы, за которыми исключение не действует.** Оно про починку названного
|
||||
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
|
||||
@@ -1177,8 +1361,102 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
|
||||
выпуском.
|
||||
|
||||
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
|
||||
единственное, и относится только к окну между `S8-merged` и выпуском.
|
||||
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
|
||||
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
|
||||
на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
|
||||
окну между `S8-merged` и выпуском.
|
||||
|
||||
### 11.5 Независимое ревью линии перед стабильным релизом
|
||||
|
||||
Решение владельца 2026-09-25, issue #638.
|
||||
|
||||
**Зачем.** Инкрементальное ревью судит дифф задачи против её ТЗ, а не
|
||||
поверхность против пользователя. Пять раундов по эпику #591 не нашли того, что
|
||||
нашли четыре независимых ревью перед аудитом 22.09: невидимый после крестика HA
|
||||
диалог (#607), кламп по символу (#608), маршруты робота при импорте (#611),
|
||||
детерминированный отказ релизного гейта (#619). Ни ветка `ha-dialog`, ни
|
||||
посимвольный ввод не входили ни в один AC.
|
||||
|
||||
**Шаг.** Перед каждым стабильным релизом — одно ревью поверхностей, изменённых
|
||||
всей линией бет, «с нуля»:
|
||||
|
||||
- **вход** — issue линии, доказанные трейлерами `Issue: #NN` в диапазоне
|
||||
«прошлый стабильный тег..кандидат» (тот же построитель и та же схема, что
|
||||
`RELEASE-MEMBERSHIP.json` беты, #547; метка S8 доказательством не является),
|
||||
и изменённые продуктовые файлы. Собирает их
|
||||
`scripts/release-review.mjs prepare`;
|
||||
- **без ТЗ и без документов раундов**: основа суждения — `docs/SCOPE.md` и
|
||||
`docs/USER-GUIDE.ru.md`. Проверка исполнением: бандл, стенд и смоки, пиннутая
|
||||
фикстура `ha-dialog` (#505) там, где есть диалоги, посимвольный ввод,
|
||||
настоящие Escape и крестик; для каждой поверхности — обычный сценарий и самый
|
||||
рискованный соседний (§2.6, шесть классов риска);
|
||||
- **выход** — `docs/reviews/RELEASE-REVIEW-vX.Y.Z.md` в `dev`, находки
|
||||
High/Medium/Low с воспроизведением.
|
||||
|
||||
**Исполнитель** — модель в CI, `.github/workflows/release-review.yml`: три job
|
||||
(вход, модель, публикация), модель без единого права на запись, документ, его
|
||||
машинный блок, индекс и коммит — детерминированный шаг. Независимость
|
||||
обеспечена построением: сессия свежая, ТЗ и раунды ей не даются.
|
||||
|
||||
**Выпуск не блокирует.** `release.yml` ставит ревью в очередь job
|
||||
`independent-review` сразу после закрепления SHA кандидата — параллельно
|
||||
гейтам; ни один job выпуска от него не зависит, его отказ — предупреждение.
|
||||
Документ — рекомендация: владелец берёт находки в работу (issue в очередь
|
||||
следующей беты) либо оставляет без действий. Автоматически находки в issue
|
||||
не превращаются.
|
||||
|
||||
Повторный запуск на тот же тег модель не тратит, если документ уже в `dev`
|
||||
(`force=true` — переснять). Ручной запуск:
|
||||
`gh workflow run release-review.yml --ref dev -f tag=vX.Y.Z [-f candidate=<sha>]`.
|
||||
Беты шаг пропускают. Первый прогон — линия v1.78.0.
|
||||
|
||||
### 11.6 Повторные Validate на одном SHA перед релизом
|
||||
|
||||
Решение владельца 2026-09-26, issue #656.
|
||||
|
||||
Релизный гейт рассматривает прогоны одного SHA от нового к старому. Отменённый
|
||||
прогон и proof, который не соответствует запрошенной политике (например,
|
||||
лёгкий вместо полного), вердиктом не являются: гейт проходит мимо них к
|
||||
следующему совместимому proof. **Среди совместимых полных прогонов решает
|
||||
новейший.** Поэтому поздний полный `failed`, `missing` или `pending` блокирует
|
||||
более ранний зелёный proof; новый полный зелёный прогон может обновить старый
|
||||
красный.
|
||||
|
||||
Это fail-closed правило. Content-addressed proof доказывает, что конкретный
|
||||
прогон относится к кандидату, но не даёт старому зелёному прогону права скрыть
|
||||
более позднюю проверку той же политики, которая нашла отказ. Чтобы продолжить
|
||||
выпуск после такого отказа, исправляют причину и получают новый совместимый
|
||||
полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата.
|
||||
|
||||
### 11.7 Пакетное ревью `ship` перед бетой
|
||||
|
||||
Решение владельца 2026-09-28, issue #696.
|
||||
|
||||
**Зачем.** `track:ship` сливается без ревью модели (§5, §10.4): правка в
|
||||
механических рамках и зелёный лёгкий Validate. Прочитать её код обязан кто-то
|
||||
до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи
|
||||
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
|
||||
|
||||
**Шаг.** Перед публикацией беты — `ship-review.yml`
|
||||
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`):
|
||||
|
||||
- **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат»
|
||||
(тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с
|
||||
маркером `hp:ship-merge` в комментариях или с меткой `track:ship`. Собирает
|
||||
`scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается;
|
||||
- **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код
|
||||
заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по
|
||||
смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
|
||||
- **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком:
|
||||
задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует
|
||||
детерминированный шаг.
|
||||
|
||||
**Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации —
|
||||
`publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`).
|
||||
Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в
|
||||
`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует
|
||||
пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью
|
||||
переснимается. Medium и Low решает владелец, как в §11.5.
|
||||
|
||||
---
|
||||
|
||||
@@ -1203,70 +1481,3 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
|
||||
**Нарушение процесса — тоже issue** (метка `process`): если правило удалось
|
||||
нарушить незаметно, виновата проверка.
|
||||
|
||||
---
|
||||
|
||||
## 13. Внедрение
|
||||
|
||||
Состояние на 2026-08-13.
|
||||
|
||||
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
|
||||
одна `S*`-метка, инварианты чистые.
|
||||
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
|
||||
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
|
||||
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
|
||||
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
|
||||
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
|
||||
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
|
||||
4. ✅ **Долг ревью списан решением владельца.** Беты `beta.2`…`beta.10` сделаны по
|
||||
прежнему процессу и не пересматриваются. Точка отсчёта — релиз 1.62.0; отсчёт
|
||||
начинается с первой беты следующей линии.
|
||||
5. ⏳ Завести issue на находку «смок `visual_continuity` не умеет падать» — это
|
||||
ровно тот класс дефектов, который в процессе без ручного тестирования стоит
|
||||
дороже всего.
|
||||
6. ✅ `BACKLOG-2026-08-11.md` — разовый отчёт, решения живут в issue.
|
||||
7. ✅ `AGENTS.md` переписан целиком, шире блока §14.
|
||||
8. ✅ **Канон перенесён в репозиторий** (issue #112). До этого полный процесс жил
|
||||
только в папке владельца, а в репозитории лежал файл на 51 строку про трейлеры
|
||||
коммитов — из свежего клона канон не был виден вообще.
|
||||
9. ✅ **`pre-push` написан** (§10.1, issue #121). Блокирующая проверка на клиенте
|
||||
есть; обойти её можно только `--no-verify`, и тогда то же найдёт CI.
|
||||
|
||||
---
|
||||
|
||||
## 14. Блок для AGENTS.md
|
||||
|
||||
```markdown
|
||||
## Процесс: код только через issue
|
||||
|
||||
Изменение продуктового кода без issue запрещено. Код меняется только из статуса
|
||||
«Готово к разработке» или дальше. Полные правила, критерии статусов и гейты —
|
||||
`docs/PROCESS.md`, читать до начала работы.
|
||||
|
||||
Жизненный цикл (статус = метка issue): `S1-new` → `S2-analysis` → `S3-spec` →
|
||||
`S4-spec-review` → `S5-ready` → `S6-in-progress` → `S7-code-review` → `S8-merged`
|
||||
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
|
||||
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
|
||||
|
||||
Инфраструктурная задача (ни одного файла класса A) делается сразу любым агентом:
|
||||
без `S*` → `S7-code-review` ↔ `S6-in-progress` → `S8-merged`. ТЗ и ревью ТЗ нет,
|
||||
код-ревью обязательно.
|
||||
|
||||
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review`.
|
||||
Deterministic prerequisites, модель и интеграция имеют отдельные пределы 55/45/55
|
||||
минут; обычно стадии заканчиваются существенно раньше. Поставив такую метку, автор
|
||||
не заканчивает работу, а ждёт смены метки опросом и продолжает по тому, чем она
|
||||
стала.
|
||||
|
||||
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
|
||||
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
|
||||
`pre-push` (ставится автоматически через `npm ci`), CI — страховка. Force-push
|
||||
в `dev` запрещён;
|
||||
- автор ≠ ревьюер, ни для ТЗ, ни для кода;
|
||||
- фазы ручного тестирования нет: автотесты пишутся в реализации, AC проверяет
|
||||
код-ревью, найденные позже дефекты — новые issue типа «баг»;
|
||||
- мелкие задачи (метка `small`, сложность ≤3): ТЗ в теле issue, ревью ТЗ
|
||||
комментарием, код-ревью — как обычно;
|
||||
- найденное вне скоупа — новый issue, а не попутная правка;
|
||||
- issue закрывает релиз-менеджер после выпуска беты, не исполнитель.
|
||||
```
|
||||
|
||||
@@ -42,6 +42,9 @@ sync across screens.
|
||||
show temperature, humidity, light state and average LQI.
|
||||
- **Light and environment.** Room fills, lamp Glow, wall shadows, a day-cycle
|
||||
backdrop and sunlight through windows.
|
||||
- **Flat or 2.5D.** One switch in General settings › Display shows the plan
|
||||
with depth everywhere: raised device tiles with soft floor shadows and a soft
|
||||
wash of sunlight; decor and wall colours stay as you set them.
|
||||
- **Doors, windows, gates and vacuums.** Openings follow real contacts and locks;
|
||||
a robot can show its position, dock and travelled path.
|
||||
- **Several floors and screens.** Space tabs, swipe navigation, local viewport,
|
||||
@@ -58,8 +61,8 @@ sync across screens.
|
||||
1. Install the integration and open **House Plan** in the Home Assistant sidebar.
|
||||
2. Create the first **space**: upload SVG/PNG/JPG/WebP, reuse an uploaded image,
|
||||
or choose no image and draw the plan by hand.
|
||||
3. In Plan, select **Room outline**, place vertices, and click the first point to
|
||||
close the outline.
|
||||
3. In Plan, select **Walls** and draw one continuous chain around the room: when
|
||||
it closes an area, the room dialog opens.
|
||||
4. Name the room and bind it to a Home Assistant area. Use “No area” for a room
|
||||
that has no devices.
|
||||
5. Open Device: devices from the bound area are already placed; drag their
|
||||
@@ -183,8 +186,11 @@ concurrent clients, but avoid editing the same object in two browsers at once.
|
||||
- [Full user guide](docs/USER-GUIDE.md)
|
||||
- [Mouse/touch/keyboard matrix](docs/USER-GUIDE.md#6-navigation-zoom-and-input)
|
||||
- [Plan tools](docs/USER-GUIDE.md#plan-tools-at-a-glance)
|
||||
- [Stairs and floor links](docs/STAIRS.md)
|
||||
- [Background editor](docs/DECOR-EDITOR.md)
|
||||
- [Robot vacuums](docs/VACUUM.md)
|
||||
- [Presence radars](docs/RADAR.md)
|
||||
- [PDF export](docs/PDF-EXPORT.md)
|
||||
- [Touch support](docs/TOUCH-SUPPORT.md)
|
||||
|
||||
<!-- docs-section: support -->
|
||||
@@ -197,9 +203,4 @@ concurrent clients, but avoid editing the same object in two browsers at once.
|
||||
Include the version, browser, logs and reproduction steps; private entity IDs
|
||||
may be replaced with fictional ones.
|
||||
|
||||
Documentation screenshots are produced by the reproducible
|
||||
`npm run build && node demo/docs/capture.mjs` command using synthetic data only. Scenario version,
|
||||
source fingerprint and every image hash are recorded in the
|
||||
[screenshot index](docs/images/screenshots.json).
|
||||
|
||||
License: [MIT](LICENSE).
|
||||
|
||||
+11
-9
@@ -43,6 +43,9 @@ Assistant — и устройства появятся на плане авто
|
||||
а карточки комнат показывают температуру, влажность, свет и средний LQI.
|
||||
- **Свет и окружение.** Заливки комнат, Glow от ламп, тени от стен, дневной фон и
|
||||
солнечные лучи из окон.
|
||||
- **Плоский план или 2.5D.** Один переключатель в «Общие настройки ›
|
||||
Отображение › Объёмный вид плана (2.5D)» показывает план с глубиной везде: приподнятые плитки устройств с мягкими
|
||||
тенями и мягкая засветка солнцем; декор и цвета стен остаются вашими.
|
||||
- **Двери, окна, ворота и пылесосы.** Проёмы отражают реальные датчики и замки;
|
||||
робот показывает позицию, базу и пройденный путь.
|
||||
- **Несколько этажей и экранов.** Вкладки пространств, жесты переключения,
|
||||
@@ -57,10 +60,11 @@ Assistant — и устройства появятся на плане авто
|
||||
## Первая рабочая комната
|
||||
|
||||
1. Установите интеграцию и откройте **House Plan** в боковом меню Home Assistant.
|
||||
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP либо выберите
|
||||
вариант без изображения, чтобы нарисовать план вручную.
|
||||
3. В редакторе «План» выберите **Контур комнаты**, поставьте вершины и замкните
|
||||
контур нажатием на первую точку.
|
||||
2. Создайте первое **пространство**: загрузите SVG/PNG/JPG/WebP, возьмите уже
|
||||
загруженное изображение либо выберите вариант без изображения, чтобы
|
||||
нарисовать план вручную.
|
||||
3. В редакторе «План» выберите **Стены** и нарисуйте непрерывную цепочку по
|
||||
периметру комнаты: когда она замкнёт область, откроется диалог комнаты.
|
||||
4. Назовите комнату и свяжите её с зоной Home Assistant. Для помещения без
|
||||
устройств выберите «Без зоны».
|
||||
5. Откройте «Устройства»: устройства связанной зоны уже размещены автоматически;
|
||||
@@ -188,8 +192,11 @@ default_floor: ground
|
||||
- [Полное руководство пользователя](docs/USER-GUIDE.ru.md)
|
||||
- [Матрица mouse/touch/keyboard](docs/USER-GUIDE.ru.md#6-навигация-масштаб-и-жесты)
|
||||
- [Инструменты плана](docs/USER-GUIDE.ru.md#инструменты-плана-в-короткой-таблице)
|
||||
- [Лестницы и переходы между этажами](docs/STAIRS.md)
|
||||
- [Редактор подложки](docs/DECOR-EDITOR.md)
|
||||
- [Роботы-пылесосы](docs/VACUUM.md)
|
||||
- [Радары присутствия](docs/RADAR.md)
|
||||
- [Экспорт в PDF](docs/PDF-EXPORT.md)
|
||||
- [Поддержка touch](docs/TOUCH-SUPPORT.md)
|
||||
|
||||
<!-- docs-section: support -->
|
||||
@@ -202,9 +209,4 @@ default_floor: ground
|
||||
обновление страницы (`Ctrl+F5`). Приложите версию, браузер, логи и шаги
|
||||
воспроизведения; приватные entity ID можно заменить вымышленными.
|
||||
|
||||
Скриншоты в документации получены воспроизводимой командой
|
||||
`npm run build && node demo/docs/capture.mjs` только на синтетических данных. Версия сценариев,
|
||||
fingerprint исходников и хеш каждого изображения находятся в
|
||||
[индексе снимков](docs/images/screenshots.json).
|
||||
|
||||
Лицензия: [MIT](LICENSE).
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Corrected derivative of the 0.4.0 built-in furniture artwork (#593, #606).
|
||||
|
||||
- `svg/menu/`: the same 33 front-view category illustrations as 0.4.0, used
|
||||
only by the lazy editor bundle.
|
||||
- `svg/menu/`: 33 front-view category illustrations, used only by the lazy
|
||||
editor bundle.
|
||||
- `svg/plan/`: 60 top-view drawings used on the plan.
|
||||
- `pack.json`: stable public ids, category links, default dimensions and names.
|
||||
The filename avoids the `*manifest.json` suffix reserved by HACS.
|
||||
@@ -16,11 +16,18 @@ Corrections to 0.4.0:
|
||||
as a read-compatible alias by the application, not duplicated in this pack.
|
||||
2. The *contents* of `plan/bookshelf.svg` and `plan/shelf_floor.svg` were swapped
|
||||
so each filename and unchanged public id shows the named furniture item.
|
||||
3. All 33 menu categories now have a plan variant. No menu artwork was changed.
|
||||
3. All 33 menu categories now have a plan variant.
|
||||
4. `menu/dishwasher.svg` uses the replacement front-view illustration supplied
|
||||
by JustBusiness in [issue #640](https://github.com/Matysh/houseplan-card/issues/640).
|
||||
Its reviewed source archive is `dishwasher.zip`, SHA-256
|
||||
`1A9B1BDAF490B35812F51E4DD3CB1C425224EC768219C31B979DD6A84E890203`;
|
||||
the path geometry is unchanged apart from safe repository normalization.
|
||||
JustBusiness supplied this asset for House Plan to use, modify and distribute
|
||||
under the repository MIT License without separate UI attribution.
|
||||
|
||||
The 93 SVG drawings originated with Sergey Matyunin (`Matysh`). He granted
|
||||
House Plan permission to use, modify and distribute them under the repository
|
||||
MIT License without separate UI attribution in
|
||||
The original 0.4.0 set of 93 SVG drawings originated with Sergey Matyunin
|
||||
(`Matysh`). He granted House Plan permission to use, modify and distribute them
|
||||
under the repository MIT License without separate UI attribution in
|
||||
[issue #593](https://github.com/Matysh/houseplan-card/issues/593#issuecomment-5739841899).
|
||||
The reviewed source archive is `houseplan-furniture-0.4.0.zip`, attached to
|
||||
[issue #593](https://github.com/Matysh/houseplan-card/issues/593), SHA-256
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 110">
|
||||
<path d="M81 82.5 L81 27.5 M81 82.5 C81 83.8807 79.8807 85 78.5 85 L31.5 85 C30.1193 85 29 83.8807 29 82.5 L29 55.5 M81 82.5 L81 40 M81 27.5 C81 26.1193 79.8807 25 78.5 25 L31.5 25 C30.1193 25 29 26.1193 29 27.5 M81 27.5 L81 40 M29 27.5 L29 55.5 M29 27.5 L29 40 M81 40 C81 38.6193 79.8807 37.5 78.5 37.5 L31.5 37.5 C30.1193 37.5 29 38.6193 29 40 M29 40 L29 55.5 M35.75 31 L45.25 31 M55.9943 55 C55.9943 55 54.6654 56.8105 54.5153 58.1429 C54.3169 59.9042 56.1139 60.6968 55.9943 62.4643 C55.8942 63.9429 54.5153 66 54.5153 66 M60.4943 55 C60.4943 55 59.1654 56.8105 59.0153 58.1429 C58.8169 59.9042 60.6139 60.6968 60.4943 62.4643 C60.3942 63.9429 59.0153 66 59.0153 66 M50.9943 55 C50.9943 55 49.6654 56.8105 49.5153 58.1429 C49.3169 59.9042 51.1139 60.6968 50.9943 62.4643 C50.8942 63.9429 49.5153 66 49.5153 66 M72 60.5 C72 69.8888 64.3888 77.5 55 77.5 C45.6112 77.5 38 69.8888 38 60.5 C38 51.1112 45.6112 43.5 55 43.5 C64.3888 43.5 72 51.1112 72 60.5 Z M66.5 60.5 C66.5 66.8513 61.3513 72 55 72 C48.6487 72 43.5 66.8513 43.5 60.5 C43.5 54.1487 48.6487 49 55 49 C61.3513 49 66.5 54.1487 66.5 60.5 Z M69.5 31 C69.5 32.1046 68.6046 33 67.5 33 C66.3954 33 65.5 32.1046 65.5 31 C65.5 29.8954 66.3954 29 67.5 29 C68.6046 29 69.5 29.8954 69.5 31 Z M77 31 C77 32.1046 76.1046 33 75 33 C73.8954 33 73 32.1046 73 31 C73 29.8954 73.8954 29 75 29 C76.1046 29 77 29.8954 77 31 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M29 76C29 77.3807 30.1193 78.5 31.5 78.5H78.5C79.8807 78.5 81 77.3807 81 76M29 76V82.5C29 83.8807 30.1193 85 31.5 85H78.5C79.8807 85 81 83.8807 81 82.5V76M29 76V40M81 76V40M81 40C81 38.6193 79.8807 37.5 78.5 37.5H31.5C30.1193 37.5 29 38.6193 29 40M81 40V27.5C81 26.1193 79.8807 25 78.5 25H31.5C30.1193 25 29 26.1193 29 27.5V40M43 42H67M35.75 31H45.25M69.5 31C69.5 32.1046 68.6046 33 67.5 33C66.3954 33 65.5 32.1046 65.5 31C65.5 29.8954 66.3954 29 67.5 29C68.6046 29 69.5 29.8954 69.5 31ZM77 31C77 32.1046 76.1046 33 75 33C73.8954 33 73 32.1046 73 31C73 29.8954 73.8954 29 75 29C76.1046 29 77 29.8954 77 31Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 791 B |
@@ -6,6 +6,7 @@ import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -39,6 +40,30 @@ from .store import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _async_flush_runtime(
|
||||
hass: HomeAssistant, entry: HouseplanConfigEntry,
|
||||
) -> None:
|
||||
"""Stop deferred writers and persist their latest state once.
|
||||
|
||||
Home Assistant stop and config-entry unload share this exact path. Both
|
||||
writers are independently guarded so one failed store cannot prevent the
|
||||
other from reaching disk during shutdown.
|
||||
"""
|
||||
recorder = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
if recorder is not None:
|
||||
try:
|
||||
await recorder.async_teardown()
|
||||
except Exception: # noqa: BLE001 - shutdown must continue with other stores
|
||||
_LOGGER.exception("House Plan: flushing vacuum trails failed")
|
||||
|
||||
virtual_lights = getattr(entry.runtime_data, "virtual_lights", None)
|
||||
if virtual_lights is not None:
|
||||
try:
|
||||
await virtual_lights.async_flush()
|
||||
except Exception: # noqa: BLE001 - shutdown must continue with other stores
|
||||
_LOGGER.exception("House Plan: flushing virtual-light state failed")
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Register global handlers (survive config-entry reloads): WS commands, HTTP view."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
@@ -48,11 +73,13 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
HouseplanContentView,
|
||||
HouseplanDecorAssetUploadView,
|
||||
HouseplanImportPreviewView,
|
||||
HouseplanPlanUploadView,
|
||||
HouseplanUploadView,
|
||||
)
|
||||
|
||||
hass.http.register_view(HouseplanUploadView())
|
||||
hass.http.register_view(HouseplanDecorAssetUploadView())
|
||||
hass.http.register_view(HouseplanPlanUploadView())
|
||||
hass.http.register_view(HouseplanContentView())
|
||||
hass.http.register_view(HouseplanImportPreviewView())
|
||||
hass.http.register_view(HouseplanFrontendAssetView())
|
||||
@@ -250,6 +277,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
# sidebar entry after a migration, repair or initial housekeeping failure.
|
||||
panel_path = Path(__file__).parent / "frontend" / "houseplan-panel.js"
|
||||
await async_setup_panel_registration(hass, entry, panel_path)
|
||||
|
||||
async def _flush_on_stop(_event) -> None:
|
||||
stop_listener[0] = None
|
||||
await _async_flush_runtime(hass, entry)
|
||||
|
||||
stop_listener = [hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _flush_on_stop)]
|
||||
|
||||
def _remove_stop_listener() -> None:
|
||||
if stop_listener[0] is not None:
|
||||
stop_listener[0]()
|
||||
stop_listener[0] = None
|
||||
|
||||
entry.async_on_unload(_remove_stop_listener)
|
||||
return True
|
||||
|
||||
|
||||
@@ -261,9 +301,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) -
|
||||
entry is loaded. Static paths cannot be unregistered by design.
|
||||
"""
|
||||
remove_panel_registration(hass)
|
||||
rec = hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if rec:
|
||||
rec.teardown()
|
||||
await _async_flush_runtime(hass, entry)
|
||||
hass.data.get(DOMAIN, {}).pop("trail_recorder", None)
|
||||
if entry.runtime_data.radar_coordinator:
|
||||
entry.runtime_data.radar_coordinator.teardown()
|
||||
entry.runtime_data.radar_coordinator = None
|
||||
|
||||
@@ -7,6 +7,7 @@ one behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY
|
||||
@@ -28,4 +29,27 @@ def may_write(hass: HomeAssistant, user) -> bool:
|
||||
# UI has always been admin-gated, and an unset option must not open every
|
||||
# write WS/HTTP path to every authenticated household user.
|
||||
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, True))
|
||||
return is_admin if admin_only else True
|
||||
if admin_only:
|
||||
return is_admin
|
||||
|
||||
# Administrators retain write access regardless of their group payload.
|
||||
# HA normally exposes admin groups too, but authorization must be based on
|
||||
# the explicit admin flag rather than incidental group serialization.
|
||||
if is_admin:
|
||||
return True
|
||||
|
||||
# Turning off ``admin_only`` grants editing to ordinary household users,
|
||||
# not to HA's explicitly read-only role (#626). ``groups`` is part of the
|
||||
# supported HA User model; if a non-admin connection cannot provide a
|
||||
# complete group list, the safe interpretation is that write permission
|
||||
# has not been established.
|
||||
groups = getattr(user, "groups", None)
|
||||
if not isinstance(groups, list) or not groups:
|
||||
return False
|
||||
group_ids: list[str] = []
|
||||
for group in groups:
|
||||
group_id = getattr(group, "id", None)
|
||||
if not isinstance(group_id, str):
|
||||
return False
|
||||
group_ids.append(group_id)
|
||||
return GROUP_ID_READ_ONLY not in group_ids
|
||||
|
||||
@@ -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.77.0"
|
||||
VERSION = "1.78.0"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
|
||||
@@ -158,6 +158,12 @@ def canonicalize_config_geometry(config: Any) -> Any:
|
||||
_lattice_fields(decor, ("x", "y"))
|
||||
_scalar_fields(decor, ("scale", "angle"))
|
||||
|
||||
for stair in _records(space.get("stairs")):
|
||||
# #663: wall/stair magnet placement is continuous like furniture.
|
||||
# Scalar cleanup removes JSON tails without moving an authored
|
||||
# physical-face contact onto the plan grid.
|
||||
_scalar_fields(stair, ("x", "y", "length", "width", "radius", "angle"))
|
||||
|
||||
# Legacy-only read boundary: v9 drafts are canonicalized before the
|
||||
# wall-model migration turns their edges into current partitions.
|
||||
for draft in _records(space.get("room_drafts")):
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
|
||||
from .store import HouseplanConfigEntry
|
||||
|
||||
# Marker metadata may contain personal notes, external links and manual filenames.
|
||||
TO_REDACT = {"link", "description", "pdfs", "name"}
|
||||
TO_REDACT = {"link", "description", "pdfs", "name", "binding", "settings"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -33,10 +33,13 @@ async def async_get_config_entry_diagnostics(
|
||||
"rooms_with_area": sum(1 for r in s.get("rooms", []) if r.get("area")),
|
||||
"partitions": len(s.get("partitions", [])),
|
||||
"wall_columns": len(s.get("wall_columns", [])),
|
||||
"stairs": len(s.get("stairs", [])),
|
||||
}
|
||||
for s in config.get("spaces", [])
|
||||
],
|
||||
"markers": async_redact_data(config.get("markers", []), TO_REDACT),
|
||||
"settings": config.get("settings", {}),
|
||||
"settings": async_redact_data(
|
||||
{"settings": config.get("settings", {})}, TO_REDACT
|
||||
)["settings"],
|
||||
"layout_entries": len(layout),
|
||||
}
|
||||
|
||||
@@ -1,109 +1,129 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "5e6b797362146dcb25e3ad594eca3a115b92cc4df3101d718fe7950b0ebfc362",
|
||||
"fingerprint": "a7aa4b718d3f5657591f9cf866d227c415e1f5f46fbc0b11ddce80a6845cf34e",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 290095,
|
||||
"initialViewGzipBytes": 300323,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 291618,
|
||||
"initialPanelGzipBytes": 301845,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2325,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-Cghq1scc.js",
|
||||
"houseplan-assets/de-DSAbt10z.js",
|
||||
"houseplan-assets/editor-DuEZ_blF.js",
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/fr-Dqa7XXi8.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-dcfYk9Ux.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DO5YuA8C.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-Fybk76vu.js",
|
||||
"houseplan-assets/iso-scene-render-DrqMFlnw.js",
|
||||
"houseplan-assets/live-interaction-runtime-CY4BdkFz.js",
|
||||
"houseplan-assets/pdf-export-uvcL70gX.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-CGySQMOD.js",
|
||||
"houseplan-assets/zigbee-topology-DmwpO4_X.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-BK3ECo6u.js"
|
||||
"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"
|
||||
],
|
||||
"lazyGzipBytes": 459921,
|
||||
"lazyGzipBytes": 456389,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-Cghq1scc.js",
|
||||
"houseplan-assets/editor-DuEZ_blF.js",
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-dcfYk9Ux.js",
|
||||
"houseplan-assets/zigbee-topology-DmwpO4_X.js"
|
||||
"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"
|
||||
],
|
||||
"lazyEditorGzipBytes": 243369,
|
||||
"lazyEditorGzipBytes": 238192,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-Cghq1scc.js",
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DO5YuA8C.js"
|
||||
"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"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 33816,
|
||||
"lazyOnboardingGzipBytes": 27950,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-DSAbt10z.js",
|
||||
"houseplan-assets/fr-Dqa7XXi8.js"
|
||||
"houseplan-assets/de-E17v3gXt.js",
|
||||
"houseplan-assets/fr-CpDKyK4Z.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 55370,
|
||||
"lazyLocaleGzipBytes": 56330,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/iso-scene-render-DrqMFlnw.js"
|
||||
"houseplan-assets/iso-scene-render-BxXtKC5L.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 17642,
|
||||
"lazyIsometricGzipBytes": 17885,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 16940,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"houseplan-assets/pdf-export-uvcL70gX.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/pdf-export-DL7VmwpB.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 126825,
|
||||
"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"
|
||||
],
|
||||
"lazyNamespaceLocaleGzipBytes": 21057,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-Cghq1scc.js",
|
||||
"sha256": "a3c6643d8fa86b46c0b14d6464331ac722fb2de5f76090f2415c394c2b45830d",
|
||||
"rawBytes": 74076,
|
||||
"gzipBytes": 23932,
|
||||
"path": "houseplan-assets/backdrop-pick-WDIF6iao.js",
|
||||
"sha256": "5f6dcedf274594075d8acd31295d8c23f6d1408c890c086825db6bcbaa6c9b21",
|
||||
"rawBytes": 54017,
|
||||
"gzipBytes": 17576,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"houseplan-assets/settings-ru-CkNFmChi.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-DSAbt10z.js",
|
||||
"sha256": "00b04a9346c92a955a778fd765078a17e164f038ae93e8bf8980c047cc5c5eae",
|
||||
"rawBytes": 92496,
|
||||
"gzipBytes": 27928,
|
||||
"path": "houseplan-assets/de-E17v3gXt.js",
|
||||
"sha256": "ed10d6c5e7a82b275c9422be67c2213219a75dba9aab0ddb3e98813249cbd1d0",
|
||||
"rawBytes": 94196,
|
||||
"gzipBytes": 28393,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DuEZ_blF.js",
|
||||
"sha256": "ffbfb66c07157e20053fd287e8e423b967322de5f633ba57ed3bdc9b6ab3f353",
|
||||
"path": "houseplan-assets/editor-CbeYCXUv.js",
|
||||
"sha256": "75887638c4d76040651a3cf52c56de10dc19e0cbc7583ee169d9b20cc5987788",
|
||||
"rawBytes": 3833,
|
||||
"gzipBytes": 1583,
|
||||
"gzipBytes": 1584,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"sha256": "551efa002ff77410c8ecd04f0b1e8eebba295e8f04f1de1c0f5973bc60343cba",
|
||||
"path": "houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"sha256": "cdab9e5e612be9b98c49193a2a6287080c13f62aaefa9a28b69bac94dd934012",
|
||||
"rawBytes": 25600,
|
||||
"gzipBytes": 5056,
|
||||
"isEntry": false,
|
||||
@@ -111,17 +131,17 @@
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-Dqa7XXi8.js",
|
||||
"sha256": "6e339f8074d2ad2325b5c93161a251f89ab4041f5bda8a90a491c165dd6aec31",
|
||||
"rawBytes": 94813,
|
||||
"gzipBytes": 27442,
|
||||
"path": "houseplan-assets/fr-CpDKyK4Z.js",
|
||||
"sha256": "ee9fe5934eea69e549ff48d2e3fdabd7c20e0d9906a6f4623314f6eea48bf269",
|
||||
"rawBytes": 96627,
|
||||
"gzipBytes": 27937,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"sha256": "4e2e255541baab68c9a71b8996f7db842cd2fcbd1466984acda21423b2279be7",
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"sha256": "f668d3c0a47a53e096f26728187b1666fbb6b421c1baa2ff1a649fa1ba5340d5",
|
||||
"rawBytes": 52035,
|
||||
"gzipBytes": 16940,
|
||||
"isEntry": false,
|
||||
@@ -129,184 +149,288 @@
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"sha256": "020967c47a73d107ac522a4dc2e458154324779405cdddc60e3548f72939e7b4",
|
||||
"rawBytes": 1028792,
|
||||
"gzipBytes": 289293,
|
||||
"path": "houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"sha256": "055b6f8ba3a472be163a15ef6e2348727b5b03e3660b9362f3aed21d7be90426",
|
||||
"rawBytes": 1059790,
|
||||
"gzipBytes": 299520,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-DSAbt10z.js",
|
||||
"houseplan-assets/editor-DuEZ_blF.js",
|
||||
"houseplan-assets/fr-Dqa7XXi8.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-dcfYk9Ux.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-DO5YuA8C.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-Fybk76vu.js",
|
||||
"houseplan-assets/iso-scene-render-DrqMFlnw.js",
|
||||
"houseplan-assets/live-interaction-runtime-CY4BdkFz.js",
|
||||
"houseplan-assets/pdf-export-uvcL70gX.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-CGySQMOD.js"
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-dcfYk9Ux.js",
|
||||
"sha256": "a594bdfea7e0eaac7be0e513cf316d28503194dd4cc156f832271ac3b4f91f2b",
|
||||
"rawBytes": 677705,
|
||||
"gzipBytes": 188432,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DZnn4pgB.js",
|
||||
"sha256": "f25cbcb0453279e3ac64eda67df52af100d96c54bb93cb657bc08c355e0484ed",
|
||||
"rawBytes": 688848,
|
||||
"gzipBytes": 192327,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Cghq1scc.js",
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"houseplan-assets/zigbee-topology-DmwpO4_X.js"
|
||||
"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"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-BK3ECo6u.js"
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-DO5YuA8C.js",
|
||||
"sha256": "2341b680e95085a18f3a4258ef54d76de360d8eb7f62c22d0bbbeb9e552a3776",
|
||||
"rawBytes": 15667,
|
||||
"gzipBytes": 4828,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-B6AiP0fG.js",
|
||||
"sha256": "4ea3584c8b2bfc86f96c587549ddce49eaf0b918923df2df8e98c9c0a548f835",
|
||||
"rawBytes": 15886,
|
||||
"gzipBytes": 4908,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-Cghq1scc.js",
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"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"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-Fybk76vu.js",
|
||||
"sha256": "9a79675b2b08e08e69f48268134b052111cdbaa804b6b270dfc3c66857c475fa",
|
||||
"rawBytes": 10759,
|
||||
"gzipBytes": 3685,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-8lnIEMWk.js",
|
||||
"sha256": "2a0f9af108b8a39889f90d7119d8e7789716c9960ea8e28af38ada07cdc91a8c",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 3765,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"houseplan-assets/zigbee-topology-DmwpO4_X.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-BK3ECo6u.js"
|
||||
"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"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-DrqMFlnw.js",
|
||||
"sha256": "516d5176c89713778220ea37910af126ac49deec11bca4835628acb9234a7c5b",
|
||||
"rawBytes": 51983,
|
||||
"gzipBytes": 17642,
|
||||
"path": "houseplan-assets/iso-scene-render-BxXtKC5L.js",
|
||||
"sha256": "4150eb9120fdaafa745761242ff3285056ceaccd7b016a16364574b90e3e2cfe",
|
||||
"rawBytes": 52970,
|
||||
"gzipBytes": 17885,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-CY4BdkFz.js",
|
||||
"sha256": "4dcf38616ab83f5a2914558399c3852b7afa6ba5878fee9f75cf406cc746dda1",
|
||||
"rawBytes": 8216,
|
||||
"gzipBytes": 3209,
|
||||
"path": "houseplan-assets/live-interaction-runtime-CJ95X8vv.js",
|
||||
"sha256": "66e70baa00f4c9470ac9a37a229652cf5c5585caa2364de4d467a1c2ebe24358",
|
||||
"rawBytes": 8370,
|
||||
"gzipBytes": 3280,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-uvcL70gX.js",
|
||||
"sha256": "206bebb97b82857af0143431a63ef1cf81cc7e869daa807278ef474cc91a57c6",
|
||||
"rawBytes": 236264,
|
||||
"gzipBytes": 109885,
|
||||
"path": "houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"sha256": "244bce2d5211106846215be6a9d3dacda4c910d06020144f9ffd5e432c5e8c00",
|
||||
"rawBytes": 675,
|
||||
"gzipBytes": 410,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-DvOAO0uu.js",
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-CMasQ4vg.js",
|
||||
"sha256": "4b34a8e5ddd9ddf67aa84d27b93a345cfca03b693f1f2513e2e046054465d817",
|
||||
"path": "houseplan-assets/pdf-export-DL7VmwpB.js",
|
||||
"sha256": "548dced1a8a54f860dbe57e61019d50f058d3ca13abb738d750a3c107c88de8b",
|
||||
"rawBytes": 236714,
|
||||
"gzipBytes": 109998,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-B9UMtPsh.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-de-DfJ7EKF9.js",
|
||||
"sha256": "0580b9f6191edb664c21dee21e5263489eaf22876bcad67ef12e440abc03e28a",
|
||||
"rawBytes": 7378,
|
||||
"gzipBytes": 2879,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-fr-Bl79E-6s.js",
|
||||
"sha256": "f1bde9db7fe884aabd25130f783a84d8fe27bc9553923c17bcb9b82ee3976475",
|
||||
"rawBytes": 7620,
|
||||
"gzipBytes": 2842,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/settings-ru-CkNFmChi.js",
|
||||
"sha256": "94f96c4a15a3a82ca8be3066629734379dd14eb2ff9e8f6acf369da2378676cb",
|
||||
"rawBytes": 10304,
|
||||
"gzipBytes": 3325,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-C_vpFucN.js",
|
||||
"sha256": "d45dbdfa17a1da9f45bfbca6d6c3cc98849fd5ebcc06c8f72fd6310b04b93aad",
|
||||
"rawBytes": 14862,
|
||||
"gzipBytes": 3426,
|
||||
"gzipBytes": 3425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-CGySQMOD.js"
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-BeGWlYtn.js",
|
||||
"sha256": "d3b46ab20f1de0a48a16737e7456c0318e9903b79df2c5f9f78082bf378e5c93",
|
||||
"rawBytes": 2611,
|
||||
"gzipBytes": 1307,
|
||||
"path": "houseplan-assets/summary-panel-metrics-D47J-OjO.js",
|
||||
"sha256": "909d2195fcfca30c2f1279e07e2d7506d470c3daec6fe864c97f7ecc4101e33d",
|
||||
"rawBytes": 2701,
|
||||
"gzipBytes": 1344,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-CGySQMOD.js",
|
||||
"sha256": "70932d7306193061fed10ac47812d912c3a59a7417f95e240f37b61457bf09a8",
|
||||
"rawBytes": 72733,
|
||||
"gzipBytes": 20171,
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-B0Dq0vzN.js",
|
||||
"sha256": "e44860a1af9ec7ffc7ac289c8c76ed1d0dcf0177bd91bbfe1981fdd0d4a62c46",
|
||||
"rawBytes": 72959,
|
||||
"gzipBytes": 20242,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/form-kit.styles-CfB4DWMs.js",
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/form-kit.styles-v5DlO12L.js",
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-CMasQ4vg.js",
|
||||
"houseplan-assets/summary-panel-metrics-BeGWlYtn.js"
|
||||
"houseplan-assets/summary-panel-editor-C_vpFucN.js",
|
||||
"houseplan-assets/summary-panel-metrics-D47J-OjO.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DmwpO4_X.js",
|
||||
"sha256": "a5f74e545476fb0048e5ae34d3e6aba6ca56d4774699b437f8400e40069a988e",
|
||||
"rawBytes": 18962,
|
||||
"gzipBytes": 7426,
|
||||
"path": "houseplan-assets/support-de-DZoi-fxk.js",
|
||||
"sha256": "f01d2d00a169519f5f402e99e9c85c9f21a23aeee2a9605d4444910f3250d6de",
|
||||
"rawBytes": 6167,
|
||||
"gzipBytes": 2396,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
],
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-BK3ECo6u.js",
|
||||
"sha256": "aa8b20e637d9d66b0affee0d0efee1bfd7defa9285d92d1bd496777fe5f3432b",
|
||||
"rawBytes": 3942,
|
||||
"gzipBytes": 1762,
|
||||
"path": "houseplan-assets/support-fr-Daosy89Q.js",
|
||||
"sha256": "e503a1b4e1b1ed31d8fa09bfe0cdc069a82c1d1627d40407472b336fbc98ccf5",
|
||||
"rawBytes": 6333,
|
||||
"gzipBytes": 2432,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/support-ru-e8-BrqtH.js",
|
||||
"sha256": "76742313c4b510163c380f8fd9c7fcaf1cc7f8bde6a41bcb0da48621a7c3a3b3",
|
||||
"rawBytes": 8376,
|
||||
"gzipBytes": 2876,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"sha256": "7c808f1139bb1c232627bfe884a579177ec653d16147d6b96066851893b312a4",
|
||||
"rawBytes": 2698,
|
||||
"gzipBytes": 1337,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"sha256": "6ee1530fd0bd7a8c476c79b83404c7d8a5b98cd0b86ee8a73048be75a70cc2c4",
|
||||
"rawBytes": 2718,
|
||||
"gzipBytes": 1354,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/topology-ru-BRKShmrQ.js",
|
||||
"sha256": "7fc89542fa860318d459ce1467a5a8931f3214a9d17f57c177ba01f2cfbbd3e8",
|
||||
"rawBytes": 3669,
|
||||
"gzipBytes": 1616,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DOAnbvlu.js",
|
||||
"sha256": "313f71677ba0739d9c978185c2136e73e8037b770eb3f9ef8561f692d654406f",
|
||||
"rawBytes": 10958,
|
||||
"gzipBytes": 4299,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js",
|
||||
"houseplan-assets/zigbee-topology-DmwpO4_X.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/topology-de-Cs36ouVE.js",
|
||||
"houseplan-assets/topology-fr-B8yoJGvc.js",
|
||||
"houseplan-assets/topology-ru-BRKShmrQ.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CKlry6R8.js",
|
||||
"sha256": "39e4a250de0e67e80d8afc02c39c1a79e42d4b3e113107bb3817f4fa90cab1e2",
|
||||
"rawBytes": 3983,
|
||||
"gzipBytes": 1789,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js",
|
||||
"houseplan-assets/namespace-language-zIuNvgGV.js",
|
||||
"houseplan-assets/zigbee-topology-DOAnbvlu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "d0b270ebad646accee58fd8cf7a130875da8b505b596f0e05ada91fffb41bb02",
|
||||
"sha256": "d4b480db1327152db7b1459c45692ea6b72fb7ac3bccb525bc06adfe1aab3b8d",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 802,
|
||||
"gzipBytes": 803,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "4150cfd182db03e56a9e4b4b680ad72ace418f9882528e526de2579e32944606",
|
||||
"sha256": "5aaa677a9c85f4df0f176be31a91ebb5022f5ce129774919269bb0409c310233",
|
||||
"rawBytes": 6165,
|
||||
"gzipBytes": 2325,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-BGma7Zjs.js"
|
||||
"houseplan-assets/houseplan-card-BYwTiVnu.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5
-5
@@ -1,14 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5e6b797362146dcb25e3ad594eca3a115b92cc4df3101d718fe7950b0ebfc362";import{az as e,l as o,aC as t,e8 as s,A as a,e9 as i,ea as l,eb as n,E as r,c}from"./houseplan-card-BGma7Zjs.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
|
||||
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
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
.computeLabel=${e=>f[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${d(h.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
${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);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2560
File diff suppressed because one or more lines are too long
-2446
File diff suppressed because one or more lines are too long
+44
File diff suppressed because one or more lines are too long
-44
File diff suppressed because one or more lines are too long
+33
File diff suppressed because one or more lines are too long
-33
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5e6b797362146dcb25e3ad594eca3a115b92cc4df3101d718fe7950b0ebfc362";import{c as a,A as e}from"./houseplan-card-BGma7Zjs.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-CGySQMOD.js";import"./form-kit.styles-CfB4DWMs.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__="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">
|
||||
<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 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5e6b797362146dcb25e3ad594eca3a115b92cc4df3101d718fe7950b0ebfc362";import{ee as e,J as t,aX as n,H as i,aa as o,a8 as r,bP as s,bO as a,aW as c,aV as l,aY as d,ef as u,eg as f,bR as m,eh as g}from"./houseplan-card-BGma7Zjs.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=d(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 y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,o,r);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,o,r)}}function*h(c,l,d=_){try{let u=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=d(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const a=t(e);if(!a)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,o,r,g.roomGeom,g.multiWallNodes)||a;v=y(v,s(c,m.physicalBodies)),yield}const _=m.cellCm/o;u+=a(v)*_*_/1e4}return u}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let o=i.next();for(;!o.done;)o=i.next();return o.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=u(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
|
||||
+1
@@ -0,0 +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};
|
||||
+7
-7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +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};
|
||||
@@ -0,0 +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};
|
||||
@@ -0,0 +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};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5e6b797362146dcb25e3ad594eca3a115b92cc4df3101d718fe7950b0ebfc362";import{n as t,b as n}from"./zigbee-topology-DmwpO4_X.js";import{aI as e}from"./houseplan-card-BGma7Zjs.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function 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(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,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=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 p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),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{f as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
+1
@@ -0,0 +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};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="5e6b797362146dcb25e3ad594eca3a115b92cc4df3101d718fe7950b0ebfc362";try{await import("./houseplan-assets/houseplan-card-BGma7Zjs.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__="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)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -36,6 +36,8 @@ from .const import (
|
||||
MAX_EXPORT_BYTES,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
MAX_PLANS_BYTES,
|
||||
MAX_PLANS_FILES,
|
||||
MIN_FREE_BYTES,
|
||||
PLANS_DIR,
|
||||
)
|
||||
@@ -50,15 +52,25 @@ from .decor_assets import (
|
||||
validate_asset,
|
||||
)
|
||||
from .import_export import ImportFailure, create_preview
|
||||
from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename
|
||||
from .plans import (
|
||||
TMP_PREFIX,
|
||||
QuotaError,
|
||||
check_quota,
|
||||
read_bounded,
|
||||
reserve_filename,
|
||||
store_plan_upload,
|
||||
)
|
||||
from .registry_snapshot import import_registry_snapshot
|
||||
from .store import get_data
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS,
|
||||
file_ext,
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
valid_space_id,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -255,13 +267,6 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
if not filename:
|
||||
return web.json_response({"error": "invalid_format"}, status=400)
|
||||
|
||||
try:
|
||||
validated = await hass.async_add_executor_job(
|
||||
validate_asset, b"".join(blocks), filename, declared_mime,
|
||||
)
|
||||
except DecorAssetError as err:
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=413 if err.code == "too_large" else 400)
|
||||
|
||||
root = Path(hass.config.path(ASSETS_DIR))
|
||||
|
||||
def _store() -> tuple[dict, bool]:
|
||||
@@ -339,9 +344,19 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
|
||||
try:
|
||||
async with runtime.upload_lock:
|
||||
# Pillow may consume far more RSS than the compressed body.
|
||||
# Serialise decode/validation as well as quota+promotion so N
|
||||
# parallel uploads cannot become N simultaneous decoders.
|
||||
validated = await hass.async_add_executor_job(
|
||||
validate_asset, b"".join(blocks), filename, declared_mime,
|
||||
)
|
||||
row, reused = await hass.async_add_executor_job(_store)
|
||||
except DecorAssetError as err:
|
||||
status = 507 if err.code == "capacity_exceeded" else 400
|
||||
status = (
|
||||
507 if err.code == "capacity_exceeded"
|
||||
else 413 if err.code == "too_large"
|
||||
else 400
|
||||
)
|
||||
return web.json_response({"error": err.code, "message": str(err)}, status=status)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan decor asset upload: store failed: %s", err)
|
||||
@@ -349,6 +364,90 @@ class HouseplanDecorAssetUploadView(HomeAssistantView):
|
||||
return web.json_response({"ok": True, "reused": reused, "asset": public_asset(row)})
|
||||
|
||||
|
||||
# Form fields other than the file are a space id (<= 64 chars) and an
|
||||
# extension; a kilobyte is generous and keeps a hostile field from buffering.
|
||||
_PLAN_FIELD_BYTES = 1024
|
||||
|
||||
|
||||
class HouseplanPlanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/plans/upload — store one space plan, return its URL.
|
||||
|
||||
The card used to send plans base64-encoded over WebSocket; above ~3 MiB the
|
||||
frame passed the 4 MiB message limit and HA closed the socket before
|
||||
`houseplan/plan/set` ever ran, so the advertised 8 MiB limit was a lie
|
||||
(#617). Multipart over HTTP has no such frame, and the body is bounded
|
||||
while it streams. The body is buffered in memory (<= MAX_PLAN_BYTES plus
|
||||
one chunk), like the decor view, and written by the same
|
||||
`store_plan_upload` that the WebSocket command uses.
|
||||
"""
|
||||
|
||||
url = "/api/houseplan/plans/upload"
|
||||
name = "api:houseplan:plan-upload"
|
||||
requires_auth = True
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
too_large = web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_PLAN_BYTES // 1024 // 1024}, status=413,
|
||||
)
|
||||
# Content-Length covers the multipart envelope too: reserve one batch
|
||||
# for it, like the other upload views, and let the streaming bound
|
||||
# below decide exactly.
|
||||
declared = request.content_length
|
||||
if declared is not None and declared > MAX_PLAN_BYTES + _FLUSH_AT:
|
||||
return too_large
|
||||
space_id: str | None = None
|
||||
ext: str | None = None
|
||||
raw: bytes | None = None
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "file":
|
||||
if raw is not None:
|
||||
return web.json_response({"error": "one_file_only"}, status=400)
|
||||
raw = await read_bounded(part, MAX_PLAN_BYTES, _CHUNK)
|
||||
if raw is None:
|
||||
return too_large
|
||||
elif part.name in ("space_id", "ext"):
|
||||
field = await read_bounded(part, _PLAN_FIELD_BYTES, _CHUNK)
|
||||
value = field.decode("utf-8", "replace") if field is not None else ""
|
||||
if part.name == "space_id":
|
||||
space_id = value
|
||||
else:
|
||||
ext = value
|
||||
except Exception as err: # noqa: BLE001 - a broken multipart stream must answer 400, not crash the view
|
||||
_LOGGER.warning("House Plan plan upload: multipart read error: %s", err)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
if space_id is None or not valid_space_id(space_id):
|
||||
return web.json_response({"error": "invalid_space_id"}, status=400)
|
||||
if ext not in PLAN_EXTENSIONS:
|
||||
return web.json_response({"error": "bad_ext"}, status=400)
|
||||
if raw is None:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
try:
|
||||
async with runtime.upload_lock:
|
||||
name = await hass.async_add_executor_job(
|
||||
partial(
|
||||
store_plan_upload, plans_dir, space_id, ext, raw,
|
||||
max_bytes=MAX_PLANS_BYTES, max_files=MAX_PLANS_FILES,
|
||||
)
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
return web.json_response({"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
|
||||
|
||||
|
||||
class HouseplanUploadView(HomeAssistantView):
|
||||
"""POST /api/houseplan/upload — save a marker file, return its URL."""
|
||||
|
||||
@@ -362,6 +461,40 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
runtime = get_data(hass)
|
||||
if runtime is None:
|
||||
return web.json_response({"error": "not_ready"}, status=503)
|
||||
|
||||
# Content-Length includes multipart boundaries and headers, not just
|
||||
# file bytes. Reserve one streaming batch for that overhead and only
|
||||
# preflight the guaranteed payload floor; otherwise a tiny file at the
|
||||
# exact store boundary would be rejected by its envelope (#498). The
|
||||
# exact staged size is checked again under the same lock immediately
|
||||
# before promotion.
|
||||
declared_size = getattr(request, "content_length", None)
|
||||
if declared_size is not None and declared_size > 0:
|
||||
if declared_size > MAX_FILE_BYTES + _FLUSH_AT:
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
status=413,
|
||||
)
|
||||
payload_floor = max(0, declared_size - _FLUSH_AT)
|
||||
if payload_floor:
|
||||
try:
|
||||
async with runtime.upload_lock:
|
||||
await hass.async_add_executor_job(
|
||||
partial(
|
||||
check_quota,
|
||||
files_root,
|
||||
payload_floor,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
)
|
||||
)
|
||||
except QuotaError as err:
|
||||
return web.json_response(
|
||||
{"error": err.reason, "detail": err.detail}, status=507
|
||||
)
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
@@ -444,21 +577,6 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
try:
|
||||
# The staged file already sits under files_root: hand it to
|
||||
# the quota as `incoming` only, not as stored usage too (#498).
|
||||
await hass.async_add_executor_job(
|
||||
partial(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,
|
||||
additional_disk_bytes=0,
|
||||
),
|
||||
)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response({"error": err.reason, "detail": err.detail}, status=507)
|
||||
except OSError:
|
||||
pass
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
@@ -480,8 +598,27 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
raise
|
||||
return name
|
||||
|
||||
def _check_and_promote() -> str:
|
||||
# The staged file already sits under files_root: hand it to
|
||||
# the quota as `incoming` only, not as stored usage too (#498).
|
||||
check_quota(
|
||||
files_root,
|
||||
tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES,
|
||||
MAX_FILES_COUNT,
|
||||
exclude=tmp_path,
|
||||
additional_disk_bytes=0,
|
||||
)
|
||||
return _promote()
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
async with runtime.upload_lock:
|
||||
name = await hass.async_add_executor_job(_check_and_promote)
|
||||
except QuotaError as err:
|
||||
_LOGGER.warning("House Plan upload refused: %s", err.detail)
|
||||
return web.json_response(
|
||||
{"error": err.reason, "detail": err.detail}, status=507
|
||||
)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
|
||||
@@ -45,12 +45,14 @@ from .validation import (
|
||||
MAX_LAYOUT,
|
||||
MAX_MARKERS,
|
||||
MAX_SPACES,
|
||||
DuplicateMarkerIdError,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
PartitionOpeningJambMarginError,
|
||||
sanitize_filename,
|
||||
sanitize_marker_id,
|
||||
validate_active_marker_ids,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
@@ -71,7 +73,7 @@ _LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
_IMPORT_ID_NAMESPACES = {
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "column",
|
||||
"space", "room", "marker", "partition", "wall", "opening", "decor", "column", "stair",
|
||||
}
|
||||
_MAX_IMPORT_LINEAGE_DEPTH = 16
|
||||
_REPORT_EXAMPLE_LIMIT = 24
|
||||
@@ -292,6 +294,15 @@ 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 []
|
||||
]
|
||||
if "stairs" in space:
|
||||
projected["stairs"] = [
|
||||
_pick_fields(stair, (
|
||||
"id", "kind", "x", "y", "angle", "direction",
|
||||
"target_space_id", "length", "width", "radius",
|
||||
"color", "opacity", "fill_color", "fill_opacity",
|
||||
))
|
||||
for stair in space.get("stairs") or []
|
||||
]
|
||||
return projected
|
||||
|
||||
|
||||
@@ -740,6 +751,7 @@ def _counts(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, int]:
|
||||
"markers": len(config.get("markers") or []),
|
||||
"openings": sum(len(sp.get("openings") or []) for sp in spaces),
|
||||
"decor": sum(len(sp.get("decor") or []) for sp in spaces),
|
||||
"stairs": sum(len(sp.get("stairs") or []) for sp in spaces),
|
||||
"layout": len(layout),
|
||||
}
|
||||
|
||||
@@ -1130,6 +1142,14 @@ def _repair_target_space_refs(
|
||||
|
||||
for space in spaces:
|
||||
space_id = str(space.get("id", "?"))
|
||||
for stair in space.get("stairs") or []:
|
||||
target = stair.get("target_space_id") if isinstance(stair, dict) else None
|
||||
mapped = replace(
|
||||
f"{space_id}:{stair.get('id', '?')}", "stair.target_space_id",
|
||||
target, resolve_space,
|
||||
)
|
||||
if mapped is not None:
|
||||
stair["target_space_id"] = mapped
|
||||
for room in space.get("rooms") or []:
|
||||
room_id = str(room.get("id", "?"))
|
||||
values = room.get("open_to")
|
||||
@@ -1177,6 +1197,17 @@ def _repair_target_space_refs(
|
||||
preserve_related(
|
||||
marker_id, "marker.vacuum.segment_map", room_id, resolve_room,
|
||||
)
|
||||
routes = vacuum.get("map_routes") if isinstance(vacuum, dict) else None
|
||||
if isinstance(routes, list):
|
||||
for route in routes:
|
||||
if not isinstance(route, dict):
|
||||
continue
|
||||
mapped = replace(
|
||||
marker_id, "marker.vacuum.map_routes.space",
|
||||
route.get("space"), resolve_space,
|
||||
)
|
||||
if mapped is not None:
|
||||
route["space"] = mapped
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
for index, ref in enumerate(controls):
|
||||
@@ -1309,7 +1340,7 @@ def build_space_merge(
|
||||
(sp,), sp.get("rooms") or [],
|
||||
sp.get("partitions") or [], sp.get("wall_segments") or [],
|
||||
sp.get("wall_columns") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [],
|
||||
sp.get("openings") or [], sp.get("decor") or [], sp.get("stairs") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
@@ -1325,7 +1356,7 @@ def build_space_merge(
|
||||
(incoming_space,), incoming_space.get("rooms") or [],
|
||||
incoming_space.get("partitions") or [], incoming_space.get("wall_segments") or [],
|
||||
incoming_space.get("wall_columns") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [],
|
||||
incoming_space.get("openings") or [], incoming_space.get("decor") or [], incoming_space.get("stairs") or [],
|
||||
)
|
||||
for item in collection
|
||||
if isinstance(item, dict)
|
||||
@@ -1349,6 +1380,7 @@ def build_space_merge(
|
||||
("rooms", "room"), ("partitions", "partition"),
|
||||
("wall_segments", "wall"),
|
||||
("wall_columns", "column"), ("openings", "opening"), ("decor", "decor"),
|
||||
("stairs", "stair"),
|
||||
):
|
||||
for item in space.get(collection) or []:
|
||||
if isinstance(item, dict) and item.get("id") is not None:
|
||||
@@ -1392,6 +1424,16 @@ def build_space_merge(
|
||||
reference_report, "incoming", "opening.host",
|
||||
str(opening.get("id", "?")), old_host_id,
|
||||
)
|
||||
# A one-space export cannot carry a trustworthy external floor target.
|
||||
# Self-links are invalid too because the imported source gets a new id.
|
||||
for stair in space.get("stairs") or []:
|
||||
target = stair.get("target_space_id") if isinstance(stair, dict) else None
|
||||
if target is not None:
|
||||
stair["target_space_id"] = None
|
||||
_report_remap(
|
||||
reference_report, "incoming", "stair.target_space_id",
|
||||
str(stair.get("id", "?")), str(target),
|
||||
)
|
||||
space["id"] = new_space_id
|
||||
space["title"] = _unique_title(
|
||||
str(space.get("title") or old_space_id), current_config.get("spaces") or []
|
||||
@@ -1464,6 +1506,21 @@ def build_space_merge(
|
||||
new_id, old_room_id,
|
||||
)
|
||||
vacuum["segment_map"] = remapped_segments
|
||||
# A single-space export can retain only routes owned by that space.
|
||||
# The copy itself gets a fresh id, so the retained route must follow
|
||||
# it just like marker.space does; otherwise foreign imports fail the
|
||||
# referential validator and same-instance imports silently target the
|
||||
# source floor (#611).
|
||||
if isinstance(vacuum, dict) and isinstance(vacuum.get("map_routes"), list):
|
||||
for route in vacuum["map_routes"]:
|
||||
if not isinstance(route, dict) \
|
||||
or str(route.get("space")) != old_space_id:
|
||||
continue
|
||||
route["space"] = new_space_id
|
||||
_report_remap(
|
||||
reference_report, "incoming", "marker.vacuum.map_routes.space",
|
||||
new_id, old_space_id,
|
||||
)
|
||||
if binding in duplicate and duplicate_policy == "virtual":
|
||||
virtualized += 1
|
||||
virtualized_targets.add(old_id)
|
||||
@@ -1861,6 +1918,14 @@ def _materialize_import_candidate(
|
||||
config = CONFIG_SCHEMA(config)
|
||||
except vol.Invalid as err:
|
||||
raise ImportFailure("invalid_config", str(err)) from err
|
||||
try:
|
||||
validate_active_marker_ids(
|
||||
config,
|
||||
current_config if prepared["kind"] == "space" else None,
|
||||
validate_all=prepared["kind"] == "full",
|
||||
)
|
||||
except DuplicateMarkerIdError as err:
|
||||
raise ImportFailure(err.code, str(err)) from err
|
||||
try:
|
||||
layout = LAYOUT_SCHEMA(layout)
|
||||
except vol.Invalid as err:
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.77.0"
|
||||
"version": "1.78.0"
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .const import MIN_FREE_BYTES, PLAN_ORPHAN_TTL_S
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
@@ -25,6 +27,21 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
|
||||
def atomic_write(path: Path, data: bytes, *, prefix: str = ".upload-") -> None:
|
||||
"""Durably replace ``path`` without exposing a partial destination file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=prefix, dir=str(path.parent))
|
||||
temp = Path(temp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp, path)
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
@@ -261,6 +278,63 @@ def check_quota(
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
class _ChunkReader(Protocol):
|
||||
async def read_chunk(self, size: int = ...) -> bytes: ...
|
||||
|
||||
|
||||
async def read_bounded(part: _ChunkReader, limit: int, chunk: int) -> bytes | None:
|
||||
"""Read one multipart part into memory, or None once it passes ``limit``.
|
||||
|
||||
The bound is inclusive: exactly ``limit`` bytes is accepted, one more is
|
||||
refused. Reading stops at the first block that crosses it, so an oversized
|
||||
upload never costs more than ``limit + chunk`` bytes of memory and nothing
|
||||
touches the disk (#617). Pure — the part only has to offer ``read_chunk``.
|
||||
"""
|
||||
blocks: list[bytes] = []
|
||||
size = 0
|
||||
while block := await part.read_chunk(chunk):
|
||||
size += len(block)
|
||||
if size > limit:
|
||||
return None
|
||||
blocks.append(block)
|
||||
return b"".join(blocks)
|
||||
|
||||
|
||||
def store_plan_upload(
|
||||
plans_dir: Path,
|
||||
space_id: str,
|
||||
ext: str,
|
||||
raw: bytes,
|
||||
*,
|
||||
max_bytes: int,
|
||||
max_files: int,
|
||||
) -> str:
|
||||
"""Write one validated plan upload under a new name and return that name.
|
||||
|
||||
The single writer behind both transports — the HTTP view and the legacy
|
||||
WebSocket ``houseplan/plan/set`` (#617) — so naming, quota and the atomic
|
||||
write cannot drift between them. The caller has already checked
|
||||
``space_id``, ``ext`` and the size, holds ``runtime.upload_lock`` and runs
|
||||
this as ONE executor job: the quota measurement is only a bound if nothing
|
||||
else writes between it and our write (HP-1490-02). A failed write reserves
|
||||
nothing — the file either exists and is counted by the next scan, or does
|
||||
not and is not.
|
||||
|
||||
Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||
deleted here (review R2-1). The old name stays readable, so a config write
|
||||
that is later rejected leaves the stored plan exactly as it was; the file a
|
||||
commit REPLACES is collected by ``config/set`` itself (review R3-1).
|
||||
|
||||
``.`` separates the id from the token because a space id cannot contain one
|
||||
(SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
files of a differently named space.
|
||||
"""
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{ext}"
|
||||
check_quota(plans_dir, len(raw), max_bytes, max_files)
|
||||
atomic_write(plans_dir / name, raw, prefix=".plan-upload-")
|
||||
return name
|
||||
|
||||
|
||||
def plan_basename(url: Any) -> str:
|
||||
"""File name a stored plan_url points at ('' when there is none)."""
|
||||
if not isinstance(url, str) or not url:
|
||||
|
||||
@@ -123,4 +123,7 @@ rules:
|
||||
comment: No dependencies.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: No outgoing HTTP.
|
||||
comment: >-
|
||||
The optional user-confirmed support relay is the integration's only
|
||||
outgoing HTTP path and creates its bounded ClientSession explicitly;
|
||||
ordinary plan, device and asset operation remains local.
|
||||
|
||||
@@ -81,6 +81,7 @@ class HouseplanData:
|
||||
store: HouseplanStore
|
||||
config_store: HouseplanStore
|
||||
virtual_light_store: HouseplanStore
|
||||
virtual_lights: Any | None = None
|
||||
# One lock for every load→modify→save cycle of both stores: prevents
|
||||
# lost updates from concurrent WS calls and makes the rev check atomic.
|
||||
write_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
@@ -118,7 +119,7 @@ HouseplanConfigEntry = ConfigEntry[HouseplanData]
|
||||
|
||||
def create_data(hass: HomeAssistant) -> HouseplanData:
|
||||
"""Create the stores for a config entry."""
|
||||
return HouseplanData(
|
||||
data = HouseplanData(
|
||||
store=HouseplanStore(hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_MINOR_VERSION),
|
||||
config_store=HouseplanStore(
|
||||
hass, STORAGE_VERSION, STORAGE_CONFIG_KEY, minor_version=STORAGE_MINOR_VERSION
|
||||
@@ -130,6 +131,10 @@ def create_data(hass: HomeAssistant) -> HouseplanData:
|
||||
minor_version=STORAGE_MINOR_VERSION,
|
||||
),
|
||||
)
|
||||
from .virtual_lights import VirtualLightController
|
||||
|
||||
data.virtual_lights = VirtualLightController(hass, data.virtual_light_store)
|
||||
return data
|
||||
|
||||
|
||||
def get_data(hass: HomeAssistant) -> HouseplanData | None:
|
||||
@@ -229,12 +234,17 @@ async def async_save_config_state(
|
||||
from .virtual_lights import async_reconcile_virtual_lights
|
||||
|
||||
try:
|
||||
controller = getattr(runtime, "virtual_lights", None)
|
||||
if controller is not None:
|
||||
await controller.async_flush()
|
||||
await async_reconcile_virtual_lights(
|
||||
runtime.virtual_light_store,
|
||||
canonical_config,
|
||||
rev,
|
||||
previous_config_rev=previous_rev,
|
||||
)
|
||||
if controller is not None:
|
||||
controller.reset()
|
||||
except Exception: # noqa: BLE001 - config commit already stands
|
||||
_LOGGER.exception("House Plan: virtual-light state reconciliation failed")
|
||||
return payload
|
||||
|
||||
@@ -155,6 +155,9 @@ def _global_settings(value: object) -> dict[str, Any]:
|
||||
show_room_tooltip = value.get("show_room_tooltip")
|
||||
if isinstance(show_room_tooltip, bool):
|
||||
out["show_room_tooltip"] = show_room_tooltip
|
||||
volumetric_view = value.get("volumetric_view")
|
||||
if isinstance(volumetric_view, bool):
|
||||
out["volumetric_view"] = volumetric_view
|
||||
fill_colors = value.get("fill_colors")
|
||||
if isinstance(fill_colors, dict):
|
||||
palette = {
|
||||
@@ -371,6 +374,18 @@ def _project_space(ids: _Pseudonyms, space: dict[str, Any], index: int) -> dict[
|
||||
out["decor"] = [
|
||||
_project_decor(ids, item) for item in space.get("decor") or [] if isinstance(item, dict)
|
||||
]
|
||||
out["stairs"] = []
|
||||
for item in space.get("stairs") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
projected = {"id": ids.get("stair", item.get("id"))}
|
||||
projected.update(_copy_keys(item, (
|
||||
"kind", "x", "y", "angle", "direction", "length", "width", "radius",
|
||||
"color", "opacity", "fill_color", "fill_opacity",
|
||||
)))
|
||||
if item.get("target_space_id"):
|
||||
projected["target_space_id"] = ids.get("space", item.get("target_space_id"))
|
||||
out["stairs"].append(projected)
|
||||
out["open_spans"] = [
|
||||
{"a": _point(item.get("a")), "b": _point(item.get("b"))}
|
||||
for item in space.get("open_spans") or [] if isinstance(item, dict)
|
||||
@@ -433,6 +448,7 @@ def _summary(config: object, layout: object) -> dict[str, Any]:
|
||||
"columns": sum(len(space.get("wall_columns") or []) for space in spaces if isinstance(space, dict)),
|
||||
"openings": dict(sorted(kinds.items())),
|
||||
"decor": dict(sorted(decor_kinds.items())),
|
||||
"stairs": sum(len(space.get("stairs") or []) for space in spaces if isinstance(space, dict)),
|
||||
"markers": {
|
||||
"total": len(markers),
|
||||
"lifecycle": dict(sorted(lifecycles.items())),
|
||||
|
||||
@@ -31,6 +31,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"rooms": sum(len(s.get("rooms", [])) for s in config.get("spaces", [])),
|
||||
"partitions": sum(len(s.get("partitions", [])) for s in config.get("spaces", [])),
|
||||
"wall_columns": sum(len(s.get("wall_columns", [])) for s in config.get("spaces", [])),
|
||||
"stairs": sum(len(s.get("stairs", [])) for s in config.get("spaces", [])),
|
||||
"markers": len(config.get("markers", [])),
|
||||
"layout_entries": len(layout_raw.get("layout", {})),
|
||||
}
|
||||
|
||||
@@ -455,16 +455,30 @@ class TrailRecorder:
|
||||
self.hass, sorted(ents), self._on_state
|
||||
)
|
||||
|
||||
def teardown(self) -> None:
|
||||
def _close_subscriptions(self) -> bool:
|
||||
"""Stop callbacks/timers and report whether a save was pending."""
|
||||
# HP-1540-05: flag FIRST — a refresh parked on its awaited load must
|
||||
# not re-subscribe after this cleanup has already run
|
||||
# not re-subscribe after this cleanup has already run.
|
||||
self._closed = True
|
||||
if self._unsub_track:
|
||||
self._unsub_track()
|
||||
self._unsub_track = None
|
||||
pending = self._unsub_save is not None
|
||||
if self._unsub_save:
|
||||
self._unsub_save()
|
||||
self._unsub_save = None
|
||||
return pending
|
||||
|
||||
async def async_teardown(self) -> None:
|
||||
"""Stop the recorder and durably flush a pending debounced save."""
|
||||
async with self._refresh_lock:
|
||||
pending = self._close_subscriptions()
|
||||
if pending:
|
||||
await self.store.async_save(self.book.data)
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Synchronous emergency cleanup for already-closing event loops."""
|
||||
self._close_subscriptions()
|
||||
|
||||
def _vacuum_entity(self, m: dict[str, Any]) -> str | None:
|
||||
b = str(m.get("binding") or "")
|
||||
|
||||
@@ -42,6 +42,62 @@ class MarkerControlError(ValueError):
|
||||
self.code = code
|
||||
|
||||
|
||||
class DuplicateMarkerIdError(ValueError):
|
||||
"""A write introduced or retained a changed duplicate active marker id."""
|
||||
|
||||
code = "invalid_config"
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Marker ids may contain user-controlled entity/device identifiers. A
|
||||
# stable message is enough and does not leak the value into logs.
|
||||
super().__init__("duplicate active marker id")
|
||||
|
||||
|
||||
def _active_marker_groups(config: dict | None) -> dict[str, list[str]]:
|
||||
"""Return order-independent structural signatures grouped by live id."""
|
||||
groups: dict[str, list[str]] = {}
|
||||
for marker in (config or {}).get("markers") or []:
|
||||
if not isinstance(marker, dict) or marker.get("removed") is True:
|
||||
continue
|
||||
marker_id = marker.get("id")
|
||||
if not isinstance(marker_id, str) or not marker_id:
|
||||
continue
|
||||
groups.setdefault(marker_id, []).append(
|
||||
json.dumps(marker, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
)
|
||||
for signatures in groups.values():
|
||||
signatures.sort()
|
||||
return groups
|
||||
|
||||
|
||||
def validate_active_marker_ids(
|
||||
config: dict,
|
||||
previous: dict | None = None,
|
||||
*,
|
||||
validate_all: bool = False,
|
||||
) -> None:
|
||||
"""Reject new active-id ambiguity without stranding legacy documents.
|
||||
|
||||
Existing duplicate groups may round-trip unchanged so an unrelated save
|
||||
remains possible. Once any member changes, the candidate must repair the
|
||||
group down to at most one active marker. Authoritative imports pass
|
||||
``validate_all=True`` because they have no local legacy group to preserve.
|
||||
"""
|
||||
candidate = _active_marker_groups(config)
|
||||
duplicates = {
|
||||
marker_id: signatures
|
||||
for marker_id, signatures in candidate.items()
|
||||
if len(signatures) > 1
|
||||
}
|
||||
if not duplicates:
|
||||
return
|
||||
if validate_all or previous is None:
|
||||
raise DuplicateMarkerIdError()
|
||||
old = _active_marker_groups(previous)
|
||||
if any(old.get(marker_id) != signatures for marker_id, signatures in duplicates.items()):
|
||||
raise DuplicateMarkerIdError()
|
||||
|
||||
|
||||
class OpeningPassageError(ValueError):
|
||||
"""Semantic open-passage error with a stable public code and payload."""
|
||||
|
||||
@@ -1142,6 +1198,7 @@ MAX_ROOMS = 400
|
||||
MAX_MARKERS = 2000
|
||||
MAX_OPENINGS = 500
|
||||
MAX_DECOR = 1000
|
||||
MAX_STAIRS = 250
|
||||
# v8 atomises room boundaries. The 2 MiB wire cap is the practical bound; this
|
||||
# structural cap mirrors MAX_ROOMS * MAX_POLY_POINTS without depending on the
|
||||
# later constant declaration.
|
||||
@@ -1151,7 +1208,7 @@ _LEGACY_MAX_ROOM_DRAFTS = 200
|
||||
_LEGACY_MAX_DRAFT_SEGMENTS = 2000
|
||||
MAX_PARTITIONS = 2000
|
||||
MAX_WALL_COLUMNS = 500
|
||||
# Open (virtual) wall stretches, docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
|
||||
# Open (virtual) wall stretches, legacy/docs/superpowers/specs/2026-08-05-open-spans-delete-design.md.
|
||||
# Every span is a piece of a shared boundary, so there can never be more of
|
||||
# them than there are wall segments — the cap is the walls' one (AUD-159B6-03).
|
||||
MAX_OPEN_SPANS = 500
|
||||
@@ -1223,7 +1280,7 @@ _GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
|
||||
# than the old unit square — while staying strictly positive.
|
||||
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
|
||||
|
||||
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
|
||||
# The backdrop's uniform scale (docs/DECOR-EDITOR.md §3). A MULTIPLIER, not a
|
||||
# coordinate: strictly positive, and bounded by what a person could mean —
|
||||
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
|
||||
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
|
||||
@@ -1371,7 +1428,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
|
||||
# Live text on a decor label (docs/DECOR-EDITOR.md §5). An entity id is
|
||||
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
|
||||
# underscores in both halves. The bound is a sanity limit, not a policy.
|
||||
MAX_ENTITY_ID = 255
|
||||
@@ -1433,7 +1490,7 @@ DECOR_SCHEMA = vol.Any(
|
||||
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
|
||||
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
|
||||
# the template: newlines are the user's own line breaks and are
|
||||
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
|
||||
# kept verbatim (docs/DECOR-EDITOR.md §5); the label never wraps itself
|
||||
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
|
||||
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
|
||||
# — the block is scaled by its corner handles — but a plan
|
||||
@@ -1476,6 +1533,61 @@ DECOR_SCHEMA = vol.Any(
|
||||
)
|
||||
|
||||
|
||||
def _strict_stair(value: dict) -> dict:
|
||||
"""A discriminated stair record carries dimensions for exactly one kind."""
|
||||
if value["kind"] == "straight":
|
||||
if "radius" in value:
|
||||
raise vol.Invalid("radius is allowed only for spiral stairs")
|
||||
if value["direction"] not in ("forward", "backward"):
|
||||
raise vol.Invalid("straight stair direction must be forward or backward")
|
||||
else:
|
||||
if "length" in value or "width" in value:
|
||||
raise vol.Invalid("length and width are allowed only for straight stairs")
|
||||
if value["direction"] not in ("clockwise", "counterclockwise"):
|
||||
raise vol.Invalid("spiral stair direction must be clockwise or counterclockwise")
|
||||
return value
|
||||
|
||||
|
||||
def _stair_dimensions(value: dict) -> dict:
|
||||
"""Require the dimension fields owned by the selected stair kind."""
|
||||
complete = (
|
||||
value["kind"] == "straight" and "length" in value and "width" in value
|
||||
or value["kind"] == "spiral" and "radius" in value
|
||||
)
|
||||
if not complete:
|
||||
raise vol.Invalid("stair kind requires its dimensions")
|
||||
return value
|
||||
|
||||
|
||||
STAIR_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("id"): vol.All(str, vol.Length(min=1, max=64)),
|
||||
vol.Required("kind"): vol.In(["straight", "spiral"]),
|
||||
vol.Required("x"): _NORM,
|
||||
vol.Required("y"): _NORM,
|
||||
vol.Required("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
|
||||
vol.Required("direction"): vol.In([
|
||||
"forward", "backward", "clockwise", "counterclockwise",
|
||||
]),
|
||||
vol.Optional("target_space_id"): vol.Any(
|
||||
None, vol.All(str, vol.Match(SPACE_ID_RE.pattern))
|
||||
),
|
||||
vol.Optional("length"): _FURN_SIZE,
|
||||
vol.Optional("width"): _FURN_SIZE,
|
||||
vol.Optional("radius"): _FURN_SIZE,
|
||||
vol.Optional("color"): _COLOR,
|
||||
vol.Optional("opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
vol.Optional("fill_color"): _COLOR,
|
||||
vol.Optional("fill_opacity"): vol.All(_finite, vol.Range(min=0.0, max=1.0)),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
_stair_dimensions,
|
||||
_strict_stair,
|
||||
)
|
||||
|
||||
|
||||
def _wall_endpoints_pair(entry: dict) -> dict:
|
||||
"""Exact wall endpoints are useful only as a complete a/b pair."""
|
||||
if ("a" in entry) != ("b" in entry):
|
||||
@@ -1617,7 +1729,7 @@ OPENING_HOST_SCHEMA = vol.Any(PARTITION_OPENING_HOST_SCHEMA, WALL_OPENING_HOST_S
|
||||
def _space_geometry_invariants(value: dict) -> dict:
|
||||
"""All stored geometry shares ids; draft segments also have a space cap."""
|
||||
seen: set[str] = set()
|
||||
for key in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns", "wall_segments"):
|
||||
for key in ("rooms", "openings", "decor", "stairs", "room_drafts", "partitions", "wall_columns", "wall_segments"):
|
||||
for item in value.get(key, []):
|
||||
item_id = item.get("id")
|
||||
if not item_id:
|
||||
@@ -1687,7 +1799,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Optional("plan_aspect"): vol.Any(
|
||||
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
|
||||
),
|
||||
# Backdrop placement (docs/BACKDROP.md): the picture may be moved,
|
||||
# Backdrop placement (docs/DECOR-EDITOR.md §3): the picture may be moved,
|
||||
# resized per axis and rotated. Every transform field is optional; its
|
||||
# complete absence is the pre-v1.58.0 behaviour exactly, and an old
|
||||
# config validates unchanged. The offset is a normalised
|
||||
@@ -1712,6 +1824,7 @@ SPACE_SCHEMA = vol.All(vol.Schema(
|
||||
vol.Required("view_box"): _view_box,
|
||||
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||
vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS)),
|
||||
vol.Optional("openings"): vol.All([
|
||||
vol.Schema(
|
||||
{
|
||||
@@ -2198,6 +2311,8 @@ CONFIG_SCHEMA = vol.All(
|
||||
vol.Optional("sun_rays"): bool,
|
||||
vol.Optional("sun_ray_origin"): _SUN_RAY_ORIGIN,
|
||||
vol.Optional("show_room_tooltip"): bool,
|
||||
# #649: installation-wide 2.5D View; absent/false keeps Flat.
|
||||
vol.Optional("volumetric_view"): 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
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
"""Persistent operational state for manual virtual lights."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .store import HouseplanStore
|
||||
|
||||
|
||||
EVENT_VIRTUAL_LIGHT_UPDATED = "houseplan_virtual_light_updated"
|
||||
SAVE_DELAY_S = 0.5
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_manual_virtual_light(marker: Any) -> bool:
|
||||
@@ -57,6 +64,119 @@ def _wire(rev: int, config_rev: int, off: set[str]) -> dict[str, Any]:
|
||||
return {"rev": rev, "config_rev": config_rev, "off": sorted(off)}
|
||||
|
||||
|
||||
def _snapshot_payload(
|
||||
stored: Any,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
*,
|
||||
previous_config_rev: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
expected_rev = config_rev if previous_config_rev is None else previous_config_rev
|
||||
off = stored_off & eligible if state_config_rev == expected_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
return _wire(rev, config_rev, off)
|
||||
|
||||
|
||||
class VirtualLightController:
|
||||
"""Runtime cache with coalesced durable writes for rapid toggles."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, store: HouseplanStore) -> None:
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
self._state: dict[str, Any] | None = None
|
||||
self._dirty = False
|
||||
self._save_task: asyncio.Task[None] | None = None
|
||||
self._flush_event = asyncio.Event()
|
||||
self._flush_lock = asyncio.Lock()
|
||||
|
||||
async def async_snapshot(
|
||||
self, config: dict[str, Any], config_rev: int,
|
||||
) -> dict[str, Any]:
|
||||
source = self._state
|
||||
if source is None:
|
||||
source = await self.store.async_load() or {}
|
||||
payload = _snapshot_payload(source, config, config_rev)
|
||||
self._state = payload
|
||||
if payload != source:
|
||||
self._dirty = True
|
||||
self._schedule_save()
|
||||
return copy.deepcopy(payload)
|
||||
|
||||
async def async_toggle(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
config_rev: int,
|
||||
marker_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if marker_id not in eligible_virtual_light_ids(config):
|
||||
return None
|
||||
snapshot = await self.async_snapshot(config, config_rev)
|
||||
off = set(snapshot["off"])
|
||||
if marker_id in off:
|
||||
off.remove(marker_id)
|
||||
else:
|
||||
off.add(marker_id)
|
||||
payload = _wire(_integer(snapshot["rev"]) + 1, config_rev, off)
|
||||
self._state = payload
|
||||
self._dirty = True
|
||||
self._schedule_save()
|
||||
return {
|
||||
"marker_id": marker_id,
|
||||
"on": marker_id not in off,
|
||||
"rev": payload["rev"],
|
||||
}
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if self._save_task is None or self._save_task.done():
|
||||
self._save_task = self.hass.async_create_task(self._delayed_save())
|
||||
|
||||
async def _delayed_save(self) -> None:
|
||||
try:
|
||||
while self._dirty:
|
||||
if not self._flush_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._flush_event.wait(), timeout=SAVE_DELAY_S,
|
||||
)
|
||||
except TimeoutError:
|
||||
pass
|
||||
if not self._dirty or self._state is None:
|
||||
continue
|
||||
payload = copy.deepcopy(self._state)
|
||||
self._dirty = False
|
||||
try:
|
||||
await self.store.async_save(payload)
|
||||
except Exception: # noqa: BLE001 - next toggle/unload retries
|
||||
self._dirty = True
|
||||
_LOGGER.exception("House Plan: virtual-light delayed save failed")
|
||||
return
|
||||
finally:
|
||||
self._save_task = None
|
||||
if not self._dirty:
|
||||
self._flush_event.clear()
|
||||
|
||||
async def async_flush(self) -> None:
|
||||
"""Persist the latest state before config transitions or unload."""
|
||||
async with self._flush_lock:
|
||||
task = self._save_task
|
||||
if task is not None:
|
||||
self._flush_event.set()
|
||||
await task
|
||||
if self._dirty and self._state is not None:
|
||||
payload = copy.deepcopy(self._state)
|
||||
await self.store.async_save(payload)
|
||||
self._dirty = False
|
||||
if not self._dirty:
|
||||
self._flush_event.clear()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Forget cache after an external reconciliation wrote the store."""
|
||||
self._state = None
|
||||
|
||||
|
||||
async def async_virtual_light_snapshot(
|
||||
store: HouseplanStore,
|
||||
config: dict[str, Any],
|
||||
@@ -70,12 +190,7 @@ async def async_virtual_light_snapshot(
|
||||
old off state for a marker whose role changed in the meantime.
|
||||
"""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
payload = _snapshot_payload(stored, config, config_rev)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
@@ -90,12 +205,9 @@ async def async_reconcile_virtual_lights(
|
||||
) -> dict[str, Any]:
|
||||
"""Carry eligible state across one known configuration transition."""
|
||||
stored = await store.async_load() or {}
|
||||
rev, state_config_rev, stored_off = _read_state(stored)
|
||||
eligible = eligible_virtual_light_ids(config)
|
||||
off = stored_off & eligible if state_config_rev == previous_config_rev else set()
|
||||
if off != stored_off:
|
||||
rev += 1
|
||||
payload = _wire(rev, config_rev, off)
|
||||
payload = _snapshot_payload(
|
||||
stored, config, config_rev, previous_config_rev=previous_config_rev,
|
||||
)
|
||||
if payload != stored:
|
||||
await store.async_save(payload)
|
||||
return payload
|
||||
|
||||
@@ -439,7 +439,7 @@ def _thickness(space: dict[str, Any], atom: dict[str, Any], previous: dict[str,
|
||||
|
||||
def _non_catalog_ids(space: dict[str, Any]) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for name in ("rooms", "openings", "decor", "room_drafts", "partitions", "wall_columns"):
|
||||
for name in ("rooms", "openings", "decor", "stairs", "room_drafts", "partitions", "wall_columns"):
|
||||
for item in space.get(name) or []:
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]:
|
||||
result.add(item["id"])
|
||||
@@ -661,7 +661,7 @@ def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]
|
||||
return 0, 0
|
||||
used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "partitions",
|
||||
for name in ("rooms", "openings", "decor", "stairs", "partitions",
|
||||
"wall_columns", "wall_segments")
|
||||
for item in space.get(name) or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str) and item["id"]
|
||||
|
||||
@@ -72,13 +72,13 @@ from .import_export import (
|
||||
from .junction_limits import JunctionLimitError, validate_junction_limits
|
||||
from .plans import (
|
||||
QuotaError,
|
||||
check_quota,
|
||||
collect_attachments,
|
||||
collect_plans,
|
||||
is_plan_file,
|
||||
plan_basename,
|
||||
plan_refs,
|
||||
reserve_filename,
|
||||
store_plan_upload,
|
||||
)
|
||||
from .projection import project_config, project_layout
|
||||
from .radar_validation import (
|
||||
@@ -113,6 +113,7 @@ from .validation import (
|
||||
MAX_PLAN_BYTES,
|
||||
PLAN_EXTENSIONS,
|
||||
POS_SCHEMA,
|
||||
DuplicateMarkerIdError,
|
||||
MarkerControlError,
|
||||
OpeningPassageError,
|
||||
PartitionOpeningHostError,
|
||||
@@ -121,6 +122,7 @@ from .validation import (
|
||||
prepare_ordinary_summary_candidate,
|
||||
sanitize_filename,
|
||||
valid_space_id,
|
||||
validate_active_marker_ids,
|
||||
validate_marker_controls,
|
||||
validate_marker_light_entities,
|
||||
validate_marker_vacuum_routes,
|
||||
@@ -141,6 +143,19 @@ from .wall_segment_model import (
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MISSING_REV_DEBUGGED: set[str] = set()
|
||||
|
||||
|
||||
def _debug_missing_revision_once(command: str, current_rev: int) -> None:
|
||||
"""Log one low-noise diagnostic per legacy write command."""
|
||||
if command in _MISSING_REV_DEBUGGED:
|
||||
return
|
||||
_MISSING_REV_DEBUGGED.add(command)
|
||||
_LOGGER.debug(
|
||||
"House Plan: %s without expected_rev over rev %s; write rejected",
|
||||
command,
|
||||
current_rev,
|
||||
)
|
||||
|
||||
def _optimizer_backup_is_current(config_data: dict[str, Any], layout_data: dict[str, Any]) -> bool:
|
||||
"""An optimization can be undone before any later ordinary plan edit."""
|
||||
@@ -440,21 +455,23 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return
|
||||
try:
|
||||
async with rt.write_lock:
|
||||
config_data = await rt.config_store.async_load() or {}
|
||||
layout_data = await rt.store.async_load() or {}
|
||||
document, filename = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_export,
|
||||
rt,
|
||||
config_data,
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
plan_only=msg.get("plan_only", False),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
# Copy one coherent pair while writers are excluded, then release
|
||||
# the global lock before hashing assets and building the document.
|
||||
config_data = copy.deepcopy(await rt.config_store.async_load() or {})
|
||||
layout_data = copy.deepcopy(await rt.store.async_load() or {})
|
||||
document, filename = await hass.async_add_executor_job(
|
||||
partial(
|
||||
create_export,
|
||||
rt,
|
||||
config_data,
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
plan_only=msg.get("plan_only", False),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
)
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
@@ -575,8 +592,11 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
"missing_plan",
|
||||
"Plan file no longer exists: " + ", ".join(sorted(missing)),
|
||||
)
|
||||
missing_attachments = _missing_internal_attachments(
|
||||
Path(hass.config.path("")), target_config, config_data.get("config")
|
||||
missing_attachments = await hass.async_add_executor_job(
|
||||
_missing_internal_attachments,
|
||||
Path(hass.config.path("")),
|
||||
target_config,
|
||||
config_data.get("config"),
|
||||
)
|
||||
if missing_attachments:
|
||||
raise ImportFailure(
|
||||
@@ -751,11 +771,7 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# indistinguishable from a stale writer. Keep the schema field
|
||||
# optional only for rev-zero bootstrap and return the stable domain
|
||||
# conflict here rather than allowing canonical no-op to bypass CAS.
|
||||
_LOGGER.warning(
|
||||
"House Plan: layout/set without expected_rev over rev %s — "
|
||||
"write rejected (outdated client?)",
|
||||
current_rev,
|
||||
)
|
||||
_debug_missing_revision_once("layout/set", current_rev)
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Layout revision is required; reload the layout, or — for "
|
||||
@@ -808,10 +824,6 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
config_data = resolved.config_data
|
||||
config = config_data.get("config") or {}
|
||||
markers = config.get("markers") or []
|
||||
deleted = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
|
||||
for m in markers
|
||||
)
|
||||
live_virtual = any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
and m.get("binding") == "virtual"
|
||||
@@ -821,6 +833,10 @@ async def ws_layout_update(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is not True
|
||||
for m in markers
|
||||
)
|
||||
deleted = not live_explicit and any(
|
||||
str(m.get("id")) == msg["device_id"] and m.get("removed") is True
|
||||
for m in markers
|
||||
)
|
||||
orphan_virtual = (
|
||||
msg["device_id"].startswith("v_")
|
||||
and not live_virtual
|
||||
@@ -1040,6 +1056,9 @@ async def ws_plans_list(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
works as a policy if the user can see them: detaching a plan keeps the
|
||||
image, and this is how it gets picked up again — or deleted on purpose.
|
||||
"""
|
||||
if not _check_write(hass, connection):
|
||||
connection.send_error(msg["id"], "unauthorized", "Only writers may list plans")
|
||||
return
|
||||
rt = _runtime(hass, connection, msg["id"])
|
||||
if rt is None:
|
||||
return
|
||||
@@ -1429,10 +1448,10 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
config_rev = int(data.get("rev", 0))
|
||||
try:
|
||||
virtual_lights = await async_virtual_light_snapshot(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
config_rev,
|
||||
virtual_lights = await (
|
||||
rt.virtual_lights.async_snapshot(config, config_rev)
|
||||
if rt.virtual_lights is not None
|
||||
else async_virtual_light_snapshot(rt.virtual_light_store, config, config_rev)
|
||||
)
|
||||
except Exception: # noqa: BLE001 - config remains independently readable
|
||||
_LOGGER.exception("House Plan: reading virtual-light state failed")
|
||||
@@ -1482,11 +1501,17 @@ async def ws_virtual_light_toggle(
|
||||
async with rt.write_lock:
|
||||
data = await rt.config_store.async_load() or {}
|
||||
config = {**DEFAULT_CONFIG, **data.get("config", {})}
|
||||
result = await async_toggle_virtual_light(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
int(data.get("rev", 0)),
|
||||
msg["marker_id"],
|
||||
result = await (
|
||||
rt.virtual_lights.async_toggle(
|
||||
config, int(data.get("rev", 0)), msg["marker_id"]
|
||||
)
|
||||
if rt.virtual_lights is not None
|
||||
else async_toggle_virtual_light(
|
||||
rt.virtual_light_store,
|
||||
config,
|
||||
int(data.get("rev", 0)),
|
||||
msg["marker_id"],
|
||||
)
|
||||
)
|
||||
if result is None:
|
||||
connection.send_error(
|
||||
@@ -1495,8 +1520,8 @@ async def ws_virtual_light_toggle(
|
||||
"Marker is not an active virtual light with tap_action=toggle",
|
||||
)
|
||||
return
|
||||
# Both the reply and event follow the durable Store write. There is no
|
||||
# optimistic client state, so all cards converge on this revision.
|
||||
# The runtime revision is immediate; durable writes are coalesced and
|
||||
# flushed before config transitions/unload.
|
||||
connection.send_result(msg["id"], result)
|
||||
hass.bus.async_fire(EVENT_VIRTUAL_LIGHT_UPDATED, result)
|
||||
|
||||
@@ -1627,11 +1652,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
# empty-store bootstrap and so this path can return the same stable
|
||||
# domain error as an explicit stale revision. Accepting it over a
|
||||
# saved document would bypass optimistic locking entirely.
|
||||
_LOGGER.warning(
|
||||
"House Plan: config/set without expected_rev over rev %s — "
|
||||
"write rejected (outdated client?)",
|
||||
current_rev,
|
||||
)
|
||||
_debug_missing_revision_once("config/set", current_rev)
|
||||
connection.send_error(
|
||||
msg["id"], "conflict",
|
||||
f"Configuration revision is required; reload the configuration, "
|
||||
@@ -1670,6 +1691,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
)
|
||||
msg["config"].clear()
|
||||
msg["config"].update(checked)
|
||||
validate_active_marker_ids(msg["config"], data.get("config"))
|
||||
validate_marker_controls(msg["config"], data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], data.get("config"))
|
||||
validate_marker_radars(
|
||||
@@ -1686,7 +1708,7 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
try:
|
||||
candidate_counts = await hass.async_add_executor_job(_validate_config_cpu)
|
||||
except (
|
||||
JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
DuplicateMarkerIdError, JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
) as err:
|
||||
@@ -1826,6 +1848,10 @@ def _space_delete_candidate(
|
||||
item for item in candidate_config.get("spaces") or []
|
||||
if item.get("id") != space_id
|
||||
]
|
||||
for remaining_space in candidate_config["spaces"]:
|
||||
for stair in remaining_space.get("stairs") or []:
|
||||
if isinstance(stair, dict) and stair.get("target_space_id") == space_id:
|
||||
stair["target_space_id"] = None
|
||||
for marker in candidate_config.get("markers") or []:
|
||||
marker_id = str(marker.get("id")) if marker.get("id") is not None else None
|
||||
marker_position = candidate_layout.get(marker_id) if marker_id is not None else None
|
||||
@@ -1916,6 +1942,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
return
|
||||
target_config = CONFIG_SCHEMA(target_config)
|
||||
validate_active_marker_ids(target_config, current_config)
|
||||
target_layout = LAYOUT_SCHEMA(target_layout)
|
||||
new_config_rev = config_rev + 1
|
||||
new_layout_rev = layout_rev + 1
|
||||
@@ -1952,6 +1979,9 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
except ImportFailure as err:
|
||||
_send_import_error(connection, msg["id"], err)
|
||||
return
|
||||
except DuplicateMarkerIdError as err:
|
||||
connection.send_error(msg["id"], err.code, str(err))
|
||||
return
|
||||
except vol.Invalid as err:
|
||||
connection.send_error(msg["id"], "invalid_config", str(err))
|
||||
return
|
||||
@@ -2058,6 +2088,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
return None, migrated_size
|
||||
msg["config"].clear()
|
||||
msg["config"].update(checked)
|
||||
validate_active_marker_ids(msg["config"], config_data.get("config"))
|
||||
validate_marker_controls(msg["config"], config_data.get("config"))
|
||||
validate_marker_light_entities(msg["config"], config_data.get("config"))
|
||||
validate_marker_radars(
|
||||
@@ -2093,7 +2124,7 @@ async def ws_plan_optimize(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
)
|
||||
return
|
||||
except (
|
||||
JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
DuplicateMarkerIdError, JunctionLimitError, MarkerControlError, OpeningPassageError,
|
||||
PartitionOpeningHostError, PartitionOpeningJambMarginError,
|
||||
WallModelClientOutdatedError,
|
||||
WallSegmentMigrationError,
|
||||
@@ -2324,38 +2355,25 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
|
||||
connection.send_error(msg["id"], "too_large", f"Plan is larger than {MAX_PLAN_BYTES // 1024 // 1024} MB")
|
||||
return
|
||||
|
||||
# Copy-on-write: a plan is written under a NEW unique name and nothing is
|
||||
# deleted here (review R2-1). The old name stays readable, so a config write
|
||||
# that is later rejected — revision conflict, validation, lost connection —
|
||||
# leaves the stored plan exactly as it was. The card calls
|
||||
# nothing here; the file a commit REPLACES is collected by `config/set`
|
||||
# itself, inside the write lock (review R3-1). An upload that never gets
|
||||
# committed is not collected at all — it is offered back in the space
|
||||
# dialog's "already uploaded" list, where the user can attach or delete it.
|
||||
# Every attempt to age these out ended in data loss or a race (v1.46.4-6).
|
||||
#
|
||||
# `.` separates the id from the token because a space id cannot contain one
|
||||
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
|
||||
# files of a differently named space.
|
||||
# Kept for cards cached from before #617: they still upload over WS. A
|
||||
# current card uses POST /api/houseplan/plans/upload instead, because the
|
||||
# base64 frame of a plan above ~3 MiB exceeds the 4 MiB WebSocket message
|
||||
# limit and the socket closes before this handler runs — the size check
|
||||
# above is reachable only for small files on this path. Both transports
|
||||
# write through the same `store_plan_upload` (copy-on-write naming, quota,
|
||||
# atomic write), so the contract below cannot drift between them.
|
||||
plans_dir = Path(hass.config.path(PLANS_DIR))
|
||||
name = f"{space_id}.{secrets.token_hex(4)}.{msg['ext']}"
|
||||
path = plans_dir / name
|
||||
|
||||
def _check_and_write() -> None:
|
||||
# one executor job for the pair, under upload_lock: the measurement
|
||||
# is only a bound if nothing else writes between it and our write
|
||||
# (HP-1490-02). A failed write reserves nothing — the file either
|
||||
# exists and is counted by the next scan, or does not and is not.
|
||||
check_quota(plans_dir, len(raw), MAX_PLANS_BYTES, MAX_PLANS_FILES)
|
||||
plans_dir.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(raw)
|
||||
|
||||
data = _runtime(hass, connection, msg["id"])
|
||||
if data is None:
|
||||
return
|
||||
async with data.upload_lock:
|
||||
try:
|
||||
await hass.async_add_executor_job(_check_and_write)
|
||||
name = await hass.async_add_executor_job(
|
||||
partial(
|
||||
store_plan_upload, plans_dir, space_id, msg["ext"], raw,
|
||||
max_bytes=MAX_PLANS_BYTES, max_files=MAX_PLANS_FILES,
|
||||
)
|
||||
)
|
||||
except QuotaError as err:
|
||||
connection.send_error(msg["id"], err.reason, err.detail)
|
||||
return
|
||||
@@ -2375,12 +2393,27 @@ async def _purge_trail_recorder(hass: HomeAssistant, config: dict[str, Any]) ->
|
||||
return await rec.async_purge_orphans(config) if rec else 0
|
||||
|
||||
|
||||
def _public_trails(value: Any) -> Any:
|
||||
"""Copy the trail book for View without exposing source entity ids (#626)."""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _public_trails(item)
|
||||
for key, item in value.items()
|
||||
if key != "source"
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_public_trails(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "houseplan/trail/get"})
|
||||
@websocket_api.async_response
|
||||
async def ws_trail_get(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict) -> None:
|
||||
"""Current + previous cleanup runs per marker, raw robot coordinates."""
|
||||
"""Current + previous runs and raw coordinates, without source entity ids."""
|
||||
rec = hass.data.get(DOMAIN, {}).get("trail_recorder")
|
||||
connection.send_result(msg["id"], {"trails": rec.book.data if rec else {}})
|
||||
connection.send_result(
|
||||
msg["id"], {"trails": _public_trails(rec.book.data) if rec else {}}
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Synthetic demo home
|
||||
|
||||
A fully fictional house (plans, devices, states) used for README screenshots,
|
||||
the demo GIF and headless smoke tests — so no real home data ever appears in
|
||||
public materials.
|
||||
|
||||
- `srv/demo.html` — self-contained host page: `<ha-icon>`/`<ha-card>` stubs and a
|
||||
fake `hass` (registries, states, `callWS`, `callService`, floors).
|
||||
- `srv/assets/` — generated plan SVGs and `icons.js` (`node demo/gen_icons.mjs`,
|
||||
needs the repo's devDependencies). The card bundle is copied from `dist/`:
|
||||
`npm run bundle:sync` (копия стенда не коммитится, #255).
|
||||
- `serve.mjs` — playwright launcher (route interception, no web server).
|
||||
- `smoke_*.mjs` — feature smoke tests; run with a Chromium installed via
|
||||
`PLAYWRIGHT_BROWSERS_PATH=<dir> npx playwright install chromium-headless-shell`.
|
||||
|
||||
Note for sandboxed sessions: `/tmp` does not survive; this directory is the
|
||||
persistent home of the harness (docs/DEVELOPMENT.md has the LD_LIBRARY_PATH recipe).
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// #291: same-process p95 of the complete config+layout boundary versus the
|
||||
// pre-existing full-candidate clone contract. Batching makes the strict 20%
|
||||
// ratio meaningful even on coarse/loaded CI timers.
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import {
|
||||
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
|
||||
} from '../test-build/coordinate-canonicalization.js';
|
||||
|
||||
const WARMUPS = 30;
|
||||
const SAMPLES = 120;
|
||||
const BATCH = 10;
|
||||
const MAX_RATIO = 1.2;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
|
||||
const baseline = () => {
|
||||
JSON.parse(JSON.stringify(fixture.config));
|
||||
JSON.parse(JSON.stringify(fixture.layout || {}));
|
||||
};
|
||||
const candidate = () => {
|
||||
canonicalizeConfigGeometry(fixture.config);
|
||||
canonicalizeLayoutGeometry(fixture.layout || {});
|
||||
};
|
||||
const measure = (operation) => {
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < BATCH; index++) operation();
|
||||
return (performance.now() - started) / BATCH;
|
||||
};
|
||||
for (let index = 0; index < WARMUPS; index++) {
|
||||
baseline();
|
||||
candidate();
|
||||
}
|
||||
const baselineSamples = [];
|
||||
const candidateSamples = [];
|
||||
for (let index = 0; index < SAMPLES; index++) {
|
||||
if (index % 2) {
|
||||
candidateSamples.push(measure(candidate));
|
||||
baselineSamples.push(measure(baseline));
|
||||
} else {
|
||||
baselineSamples.push(measure(baseline));
|
||||
candidateSamples.push(measure(candidate));
|
||||
}
|
||||
}
|
||||
const p95 = (values) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * 0.95) - 1];
|
||||
const baselineP95 = p95(baselineSamples);
|
||||
const candidateP95 = p95(candidateSamples);
|
||||
const ratio = candidateP95 / baselineP95;
|
||||
const report = {
|
||||
fixture: 'large-house-v1', samples: SAMPLES, batch: BATCH,
|
||||
baselineP95Ms: baselineP95, candidateP95Ms: candidateP95,
|
||||
ratio, limit: MAX_RATIO, pass: ratio <= MAX_RATIO,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!report.pass) process.exitCode = 1;
|
||||
@@ -40,6 +40,7 @@ const requiresIsoStructuralBuildCounter = requiresIsometric
|
||||
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
|
||||
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
|
||||
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;
|
||||
const sourceSha = (() => {
|
||||
@@ -100,7 +101,7 @@ try {
|
||||
const row = await page.evaluate(async ({
|
||||
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
|
||||
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile,
|
||||
}) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -142,7 +143,7 @@ try {
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
// Current candidates split the experimental Iso runtime out of the initial graph. Comparison
|
||||
// Current candidates split the lazy 2.5D runtime out of the initial graph. Comparison
|
||||
// bundles remain valid: their older monolithic renderer has no preload
|
||||
// hook and is already available synchronously.
|
||||
const ensureIsoRuntime = async (card) => {
|
||||
@@ -181,7 +182,7 @@ try {
|
||||
const overlaySelectors = {
|
||||
device: '[data-hp="device"]',
|
||||
'room-label': '[data-hp="room-label"]',
|
||||
'opening-lock': '.oplock',
|
||||
'opening-lock': '.oplock:not(.iso-tile-shadow)',
|
||||
};
|
||||
const totalRootsByKind = Object.fromEntries(Object.entries(overlaySelectors)
|
||||
.map(([kind, selector]) => [kind, card.renderRoot.querySelectorAll(selector).length]));
|
||||
@@ -291,6 +292,9 @@ try {
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp_alpha=1');
|
||||
// #649: current bundles read the installation setting; the alpha
|
||||
// storage above stays for comparison bundles before it.
|
||||
fixture.config.settings = { ...(fixture.config.settings || {}), volumetric_view: true };
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
@@ -386,7 +390,7 @@ try {
|
||||
throw new Error('large-house editor runtime did not preload');
|
||||
if (isometric) await ensureIsoRuntime(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
if (requiresIsometric && (typeof card._effectiveProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
|
||||
}
|
||||
@@ -397,6 +401,10 @@ try {
|
||||
await until(() => card._booting === false);
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
if (requiresStairs
|
||||
&& card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) {
|
||||
throw new Error(`${profile} did not render the bounded maximum stair collection`);
|
||||
}
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const bootDiag = {
|
||||
updates: card.__diag.updates,
|
||||
@@ -445,11 +453,20 @@ try {
|
||||
}
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
// Comparison bundles before #649: the per-device alpha preference.
|
||||
card._setProjection('flat');
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await ensureIsoRuntime(card);
|
||||
await card.updateComplete;
|
||||
} else if (typeof card._syncVolumetricSetting === 'function') {
|
||||
// #649: the installation-wide General settings switch, as a save applies it.
|
||||
const cfg = card._serverCfg;
|
||||
card._serverCfg = { ...cfg, settings: { ...(cfg.settings || {}), volumetric_view: false } };
|
||||
await card.updateComplete;
|
||||
card._serverCfg = { ...cfg, settings: { ...(cfg.settings || {}), volumetric_view: true } };
|
||||
await ensureIsoRuntime(card);
|
||||
await card.updateComplete;
|
||||
} else {
|
||||
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
||||
card.requestUpdate();
|
||||
@@ -1207,7 +1224,7 @@ try {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
requiresIsoStructuralBuildCounter, requiresStairs, profile,
|
||||
});
|
||||
// #520: диагностика печатается в лог прогона и в запись не попадает.
|
||||
const { bootDiag, ...measured } = row;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// #600 AC1: парные кадры продукта для сравнения с референсом
|
||||
// docs/design/600-settings-dialogs/ (ACCEPTANCE.md). Диагностическая съёмка, не
|
||||
// golden: кадры не принимаются golden:accept и не участвуют в golden:verify.
|
||||
// Кадры — артефакт проверки, в репозиторий не коммитятся (#681).
|
||||
//
|
||||
// node demo/capture_design_pairs_600.mjs [--out=docs/design/600-settings-dialogs/pairs]
|
||||
// node demo/capture_design_pairs_600.mjs [--out=artifacts/design-pairs-600]
|
||||
//
|
||||
// Четыре диалога × две темы, ширина поверхности 560, скроллер раскрыт на всю
|
||||
// высоту, чтобы кадр показывал форму целиком, как макет референса. Состояния —
|
||||
@@ -14,7 +15,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const out = resolve(process.argv.find((a) => a.startsWith('--out='))?.slice(6) || 'docs/design/600-settings-dialogs/pairs');
|
||||
const out = resolve(process.argv.find((a) => a.startsWith('--out='))?.slice(6) || 'artifacts/design-pairs-600');
|
||||
mkdirSync(out, { recursive: true });
|
||||
|
||||
const THEMES = {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// #649 AC12: product frames for docs/design/649-25d-stage6/ACCEPTANCE.md.
|
||||
// These scenarios are canonical golden entries (#673). This focused command
|
||||
// remains a diagnostic helper for rebuilding the side-by-side design document;
|
||||
// baseline capture and acceptance still use the complete golden matrix (#455).
|
||||
//
|
||||
// npm run build && npm run bundle:sync
|
||||
// node demo/capture_stage6_acceptance_649.mjs [out-dir]
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { launch } from './serve.mjs';
|
||||
import { goldenClip, prepareGoldenScenario } from './golden/harness.mjs';
|
||||
import { STAGE6_ACCEPTANCE_SCENARIOS } from './golden/matrix.mjs';
|
||||
|
||||
const out = process.argv[2] || 'artifacts/stage6-acceptance';
|
||||
mkdirSync(out, { recursive: true });
|
||||
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
|
||||
for (const scenario of STAGE6_ACCEPTANCE_SCENARIOS) {
|
||||
await prepareGoldenScenario(page, scenario);
|
||||
await page.waitForTimeout(2600); // the sun wash fades in over 2 s
|
||||
await page.screenshot({ path: `${out}/${scenario.id}.png`, clip: await goldenClip(page, scenario.capture) });
|
||||
console.log('captured', scenario.id);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -1,317 +0,0 @@
|
||||
/** Explicit #505 diagnostic evidence, not a golden-baseline producer or smoke. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { HA_DIALOG_PIN, prepareHaDialogAssets } from './helpers/ha-dialog-assets.mjs';
|
||||
import { launchHaDialogFixture } from './helpers/ha-dialog-fixture.mjs';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const options = { output: join(repo, 'artifacts/summary-panel-505') };
|
||||
for (let index = 2; index < process.argv.length; index++) {
|
||||
const arg = process.argv[index];
|
||||
if (arg === '--probe-only') options.probeOnly = true;
|
||||
else if (['--output', '--ha-cache', '--ha-wheel', '--only'].includes(arg)) {
|
||||
const value = process.argv[++index];
|
||||
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${arg}`);
|
||||
if (arg === '--only') options.only = value.split(',');
|
||||
else options[{ '--output': 'output', '--ha-cache': 'cacheDir', '--ha-wheel': 'wheelPath' }[arg]] = resolve(value);
|
||||
} else throw new Error(`Unknown option ${arg}`);
|
||||
}
|
||||
// Fail before the optional 124 MB download. Captures never bypass freshness.
|
||||
const fingerprint = sourceFingerprint(repo);
|
||||
if (!options.probeOnly) {
|
||||
const manifest = JSON.parse(readFileSync(join(repo, 'demo/srv/assets/houseplan-assets.json')));
|
||||
assert.equal(manifest.fingerprint, fingerprint, 'Stale demo bundle: run npm run bundle:sync before capturing');
|
||||
}
|
||||
mkdirSync(options.output, { recursive: true });
|
||||
const report = {
|
||||
issue: 505, diagnosticOnly: true, platform: process.platform,
|
||||
sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(),
|
||||
sourceFingerprint: fingerprint, probeOnly: Boolean(options.probeOnly),
|
||||
selectedCases: options.only || 'full matrix',
|
||||
haFrontend: HA_DIALOG_PIN.version, wheelSha256: HA_DIALOG_PIN.sha256,
|
||||
boundaries: 'Loopback synthetic demo only. No core/auth/HA server. Official app loader exposed at runtime; component factories/CSS unmodified.',
|
||||
comparison: 'Equivalent one block, three default system rows and one entity row. Reference row text is normalized to observed product values; reference CSS/files are unchanged. Floor-plan/fake HA chrome pixels are not parity targets.',
|
||||
captures: [],
|
||||
};
|
||||
const saveReport = () => writeFileSync(join(options.output, 'report.json'), JSON.stringify(report, null, 2));
|
||||
const assets = await prepareHaDialogAssets(options);
|
||||
|
||||
async function probe() {
|
||||
const fixture = await launchHaDialogFixture({ assets });
|
||||
try {
|
||||
const { page } = fixture;
|
||||
report.chromium = fixture.browser.version();
|
||||
await page.goto(`${fixture.url}/ha505-probe.html`);
|
||||
await page.waitForFunction(() => document.querySelector('ha-dialog')?.shadowRoot?.querySelector('wa-dialog')?.shadowRoot?.querySelector('dialog')?.matches(':modal'));
|
||||
await page.waitForTimeout(300);
|
||||
const read = () => page.evaluate(() => {
|
||||
const ha = document.querySelector('ha-dialog');
|
||||
const wa = ha.shadowRoot.querySelector('wa-dialog');
|
||||
const surface = wa.shadowRoot.querySelector('dialog');
|
||||
return { width: surface.getBoundingClientRect().width, modal: surface.matches(':modal'),
|
||||
radius: getComputedStyle(surface).borderRadius, preset: ha.width,
|
||||
inheritedMd: getComputedStyle(ha).getPropertyValue('--ha-dialog-width-md') };
|
||||
});
|
||||
const small = await read();
|
||||
await page.evaluate(async () => { const ha = document.querySelector('ha-dialog'); ha.width = 'medium'; await ha.updateComplete; });
|
||||
await page.waitForTimeout(50);
|
||||
const medium = await read();
|
||||
await page.evaluate(() => document.querySelector('#parent').style.setProperty('--ha-dialog-width-md', '920px'));
|
||||
await page.waitForTimeout(50);
|
||||
const inherited = await read();
|
||||
await page.screenshot({ path: join(options.output, 'authentic-ha-width-920.png') });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(100);
|
||||
const mobile = await read();
|
||||
await page.screenshot({ path: join(options.output, 'authentic-ha-mobile-390.png') });
|
||||
assert.equal(small.width, 320); assert.equal(medium.width, 580);
|
||||
assert.equal(inherited.width, 920); assert.equal(mobile.width, 390);
|
||||
await fixture.assertClean();
|
||||
report.probe = { small, medium, inherited, mobile, provenance: fixture.provenance,
|
||||
pageErrors: fixture.errors, externalRequests: fixture.externalRequests, websocketAttempts: fixture.websocketAttempts };
|
||||
saveReport();
|
||||
} finally { await fixture.close(); }
|
||||
}
|
||||
|
||||
const rowLabels = ['Количество устройств', 'Общая площадь комнат', 'Текущие дата и время', 'Температура кухни'];
|
||||
const productConfig = {
|
||||
version: 1, title: 'Сводная информация', show_on_mobile: true,
|
||||
blocks: [{ id: 'block-1', title: 'Общее', visible: true, scope: { type: 'all' },
|
||||
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
|
||||
source: index < 3 ? { type: 'system', key: ['device_count', 'total_area', 'datetime'][index] }
|
||||
: { type: 'entity', entity_id: 'sensor.kitchen_temperature' } })) }],
|
||||
};
|
||||
const referenceConfig = { title: productConfig.title, showOnMobile: true, blocks: [{
|
||||
...productConfig.blocks[0], scope: { type: 'all', spaceId: null },
|
||||
values: rowLabels.map((label, index) => ({ id: `value-${index + 1}`, label,
|
||||
source: index < 3 ? { type: 'system', key: ['device_count', 'room_area', 'date_time'][index], name: label }
|
||||
: { type: 'entity', entityId: 'sensor.kitchen_temperature' } })),
|
||||
}] };
|
||||
const panelCases = [
|
||||
{ name: 'desktop-light', width: 1600, height: 1000, theme: 'light' },
|
||||
{ name: 'desktop-dark', width: 1600, height: 1000, theme: 'dark' },
|
||||
{ name: 'bottom-portrait', width: 800, height: 1100, theme: 'light' },
|
||||
{ name: 'kiosk', width: 1600, height: 1000, theme: 'light', kiosk: true },
|
||||
{ name: 'mobile-view', width: 390, height: 844, theme: 'light' },
|
||||
];
|
||||
const settingCases = ['native', 'real-ha'].flatMap((shell) => [
|
||||
{ name: 'desktop', width: 1600, height: 1000 },
|
||||
{ name: 'narrow-320', width: 320, height: 844 },
|
||||
{ name: 'narrow-390', width: 390, height: 844 },
|
||||
].flatMap((size) => ['light', 'dark'].map((theme) => ({ ...size,
|
||||
name: `settings-${shell}-${size.name}-${theme}`, theme, shell, settings: true }))));
|
||||
const edgeCases = ['native', 'real-ha'].flatMap((shell) => [
|
||||
{ name: `edge-${shell}-effective-zoom-200`, width: 800, height: 500,
|
||||
adaptation: '800×500 effective viewport proxy for 1600×1000 at 200%; not actual browser zoom' },
|
||||
{ name: `edge-${shell}-text-200-long-de`, width: 1600, height: 1000, textSize: 32, language: 'de',
|
||||
adaptation: 'Root font-size 32px enlarged-text stress, long German entity name/id; not browser zoom' },
|
||||
{ name: `edge-${shell}-text-200-long-de-390`, width: 390, height: 844, textSize: 32, language: 'de',
|
||||
adaptation: '390px plus root font-size32px and long German source' },
|
||||
].map((scenario) => ({ ...scenario, shell, settings: true, theme: 'light' })));
|
||||
|
||||
function scenarioConfig(scenario, reference = false) {
|
||||
const config = structuredClone(reference ? referenceConfig : productConfig);
|
||||
if (scenario.language === 'de') {
|
||||
config.title = 'Zusammenfassung'; config.blocks[0].title = 'Allgemein';
|
||||
const row = config.blocks[0].values[3];
|
||||
row.label = 'Küchentemperatur und langfristiger Messwert';
|
||||
row.source = { type: 'entity', [reference ? 'entityId' : 'entity_id']:
|
||||
'sensor.kueche_erdgeschoss_langfristige_temperaturmessung_mit_besonders_langer_quellenkennung_505' };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function configureProduct(page, scenario) {
|
||||
await page.waitForFunction(() => window.__card?._summary && window.__card?._model?.length > 0);
|
||||
await assertFreshDemoBundle(page, repo);
|
||||
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading);
|
||||
await page.evaluate(async ({ scenario, config }) => {
|
||||
const card = window.__card;
|
||||
Object.assign(document.querySelector('#host').style, { width: '100%', height: '100vh', padding: '0', margin: '0', maxWidth: 'none' });
|
||||
Object.assign(document.body.style, { margin: '0', padding: '0' });
|
||||
card.panelHost = true;
|
||||
card.setAttribute('panel-host', '');
|
||||
Object.assign(card.style, { width: '100%', height: '100%' });
|
||||
card.setConfig({ ...card._config, language: scenario.language || 'ru', kiosk: Boolean(scenario.kiosk) });
|
||||
card._serverCfg = { ...card._serverCfg, settings: { ...card._serverCfg.settings, summary_panel: config } };
|
||||
card._cfgEpoch++;
|
||||
card._haSummaryPanelApi = 1;
|
||||
card._serverCanWrite = true;
|
||||
card.narrow = scenario.width <= 450;
|
||||
const dark = scenario.theme === 'dark';
|
||||
const colors = dark ? ['#3ea6ff', '#e6e7eb', '#9aa4ad', '#202126', '#3a3d45', '#11151b', '#2b2d33']
|
||||
: ['#0b73b8', '#202124', '#5f6368', '#ffffff', '#d7d9de', '#eef1f4', '#e7eaee'];
|
||||
['primary-color', 'primary-text-color', 'secondary-text-color', 'card-background-color', 'divider-color', 'primary-background-color', 'secondary-background-color']
|
||||
.forEach((key, index) => document.documentElement.style.setProperty(`--${key}`, colors[index]));
|
||||
document.documentElement.style.setProperty('--ha-card-background', colors[3]);
|
||||
document.documentElement.style.setProperty('--app-header-background-color', colors[3]);
|
||||
document.documentElement.style.setProperty('--app-header-text-color', colors[1]);
|
||||
document.documentElement.style.setProperty('--text-primary-color', '#ffffff');
|
||||
document.documentElement.style.colorScheme = scenario.theme;
|
||||
if (scenario.textSize) document.documentElement.style.fontSize = `${scenario.textSize}px`;
|
||||
document.body.style.background = colors[5];
|
||||
const entityId = config.blocks[0].values[3].source.entity_id;
|
||||
card.hass = { ...card.hass, language: scenario.language || 'ru', locale: { ...card.hass.locale, language: scenario.language || 'ru' },
|
||||
config: { ...card.hass.config, time_zone: 'Europe/Moscow', unit_system: { ...card.hass.config?.unit_system, length: 'm' } },
|
||||
themes: { ...card.hass.themes, darkMode: dark },
|
||||
states: { ...card.hass.states, [entityId]: { entity_id: entityId, state: '20.6', attributes: {
|
||||
friendly_name: scenario.language === 'de' ? 'Küchentemperatur im Erdgeschoss mit langfristiger Messwertüberwachung und besonders langer Quellenbezeichnung' : 'Kitchen temperature', unit_of_measurement: '°C' } } },
|
||||
};
|
||||
card._summary.updated();
|
||||
card._summary.saveLocal({ show: true });
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._summary.resized();
|
||||
}, { scenario, config: scenarioConfig(scenario) });
|
||||
await page.waitForFunction(() => window.__card._booting === false && !window.__card._bootFading && window.__card._devices.length > 0);
|
||||
if (!scenario.settings) await page.locator('.summary-overlay').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(350);
|
||||
if (scenario.settings) {
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
if (card._ensureEditorRuntime) assertRuntime(await card._ensureEditorRuntime());
|
||||
function assertRuntime(ready) { if (!ready) throw new Error('Editor runtime failed to load'); }
|
||||
await card._summary.openDialog();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await page.locator('hp-dialog[data-kind="summary"] .summary-editor').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
async function capture(scenario) {
|
||||
console.log(`Capturing ${scenario.name}`);
|
||||
const fixture = await launchHaDialogFixture({ assets, authentic: scenario.shell === 'real-ha',
|
||||
viewport: { width: scenario.width, height: scenario.height }, colorScheme: scenario.theme });
|
||||
const record = { ...scenario, files: {} };
|
||||
try {
|
||||
const { page } = fixture;
|
||||
// Let Date.now advance: the real card boot-veil quiescence uses it.
|
||||
await page.clock.setSystemTime(new Date('2026-09-09T09:00:00Z'));
|
||||
await page.goto(`${fixture.url}/product.html`);
|
||||
await configureProduct(page, scenario);
|
||||
const values = await page.evaluate((config) => config.blocks[0].values.map((value) => window.__card._summary.value(value)), scenarioConfig(scenario));
|
||||
assert.equal(values.length, 4, 'Equivalent four-row product fixture');
|
||||
record.product = await page.evaluate((settings) => {
|
||||
const root = window.__card.shadowRoot;
|
||||
const box = (node) => node ? { ...node.getBoundingClientRect().toJSON(), scrollWidth: node.scrollWidth, clientWidth: node.clientWidth } : null;
|
||||
const panel = root.querySelector('.summary-overlay');
|
||||
const hp = root.querySelector('hp-dialog[data-kind="summary"]');
|
||||
const ha = hp?.shadowRoot.querySelector('ha-dialog');
|
||||
const wa = ha?.shadowRoot.querySelector('wa-dialog');
|
||||
const surface = wa?.shadowRoot.querySelector('dialog') || hp?.shadowRoot.querySelector('.surface');
|
||||
const footer = hp?.querySelector('[slot="footer"]');
|
||||
const editor = hp?.querySelector('.summary-editor');
|
||||
const insideViewport = (node) => { const r = node?.getBoundingClientRect(); return !!r && r.left >= -1 && r.right <= innerWidth + 1 && r.top >= -1 && r.bottom <= innerHeight + 1; };
|
||||
return { panel: box(panel), control: box(root.querySelector('.summary-control')), stage: box(root.querySelector('.stage')),
|
||||
side: panel?.classList.contains('bottom') ? 'bottom' : 'right', rows: panel?.querySelectorAll('.summary-value').length,
|
||||
settings: settings ? { surface: box(surface), editor: box(editor), footer: box(footer),
|
||||
actualHA: Boolean(wa), modal: surface?.matches(':modal'), radius: getComputedStyle(surface).borderRadius,
|
||||
border: getComputedStyle(surface).border, shadow: getComputedStyle(surface).boxShadow,
|
||||
rootFont: getComputedStyle(document.documentElement).fontSize,
|
||||
titleFont: getComputedStyle(hp.shadowRoot.querySelector('.title')).fontSize,
|
||||
sourceRows: [...editor.querySelectorAll('.summary-source')].map((node) => ({ box: box(node), text: node.textContent.trim() })),
|
||||
checks: { surfaceInsideViewport: insideViewport(surface), footerVisible: insideViewport(footer),
|
||||
editorNoHorizontalOverflow: editor.scrollWidth <= editor.clientWidth + 1,
|
||||
surfaceNoHorizontalOverflow: surface.scrollWidth <= surface.clientWidth + 1,
|
||||
footerNoHorizontalOverflow: footer.scrollWidth <= footer.clientWidth + 1 },
|
||||
close: box(ha?.shadowRoot.querySelector('ha-icon-button') || hp.shadowRoot.querySelector('.close')) } : null };
|
||||
}, Boolean(scenario.settings));
|
||||
const shot = async (side, area, locator) => {
|
||||
const name = `${scenario.name}-${side}-${area}.png`;
|
||||
if (locator) await locator.screenshot({ path: join(options.output, name) });
|
||||
else await page.screenshot({ path: join(options.output, name) });
|
||||
record.files[`${side}-${area}`] = name;
|
||||
};
|
||||
await shot('product', 'viewport');
|
||||
if (scenario.settings) await shot('product', 'dialog', page.locator(scenario.shell === 'real-ha'
|
||||
? 'hp-dialog[data-kind="summary"] ha-dialog wa-dialog dialog' : 'hp-dialog[data-kind="summary"] .surface'));
|
||||
else {
|
||||
await shot('product', 'panel', page.locator('.summary-overlay'));
|
||||
await shot('product', 'control', page.locator('.summary-control'));
|
||||
}
|
||||
if (scenario.language === 'de') {
|
||||
// The genuine HA owns scrolling in its shadow .body; the native wrapper
|
||||
// owns it in the editor. Follow the composed tree instead of assuming.
|
||||
const measureSource = async (align) => page.locator('.summary-source').last().evaluate((source, align) => {
|
||||
const label = source.querySelector('strong');
|
||||
(align === 'start' ? label : source).scrollIntoView({ block: align, inline: 'nearest', behavior: 'instant' });
|
||||
const hp = source.closest('hp-dialog');
|
||||
const ha = hp.shadowRoot.querySelector('ha-dialog');
|
||||
const header = ha?.shadowRoot.querySelector('ha-dialog-header') || hp.shadowRoot.querySelector('.header');
|
||||
const footer = hp.querySelector('[slot="footer"]');
|
||||
let bandTop = Math.max(0, header?.getBoundingClientRect().bottom || 0);
|
||||
let bandBottom = Math.min(innerHeight, footer.getBoundingClientRect().top);
|
||||
const visible = (node) => { const box = node.getBoundingClientRect().toJSON();
|
||||
const visibleHeight = Math.max(0, Math.min(box.bottom, bandBottom) - Math.max(box.top, bandTop));
|
||||
return { box, visibleHeight, visibleFraction: visibleHeight / box.height }; };
|
||||
const scrollOwners = [];
|
||||
for (let node = source; node; node = node.assignedSlot || node.parentElement || node.getRootNode()?.host) {
|
||||
if (node.scrollHeight > node.clientHeight + 1 && /auto|scroll/.test(getComputedStyle(node).overflowY)) {
|
||||
const clip = node.getBoundingClientRect();
|
||||
bandTop = Math.max(bandTop, clip.top); bandBottom = Math.min(bandBottom, clip.bottom);
|
||||
scrollOwners.push({ tag: node.localName, class: node.className, scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight, clientHeight: node.clientHeight });
|
||||
}
|
||||
}
|
||||
return { align, bandTop, bandBottom, label: visible(label), source: visible(source), scrollOwners };
|
||||
}, align);
|
||||
record.product.longSourceStart = await measureSource('start');
|
||||
assert.ok(record.product.longSourceStart.label.visibleHeight > 0, 'Long source label must actually appear between header and footer');
|
||||
await shot('product', 'long-source-scrolled');
|
||||
record.product.longSourceEnd = await measureSource('end');
|
||||
assert.ok(record.product.longSourceEnd.source.visibleHeight > 0, 'Long source end must actually be visible');
|
||||
await shot('product', 'long-source-end');
|
||||
}
|
||||
await fixture.assertClean();
|
||||
await page.addInitScript(({ config, theme, kiosk, textSize }) => {
|
||||
localStorage.setItem('houseplan-dashboard5-config', JSON.stringify(config));
|
||||
localStorage.setItem('houseplan-dashboard5-enabled', 'true');
|
||||
localStorage.setItem('houseplan-dashboard5-theme', theme);
|
||||
localStorage.setItem('houseplan-dashboard5-kiosk', String(Boolean(kiosk)));
|
||||
if (textSize) document.addEventListener('DOMContentLoaded', () => { document.documentElement.style.fontSize = `${textSize}px`; });
|
||||
}, { config: scenarioConfig(scenario, true), theme: scenario.theme, kiosk: scenario.kiosk, textSize: scenario.textSize });
|
||||
await page.goto(`${fixture.url}/reference/index.html`);
|
||||
await page.locator('#dashboard3-panel').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(350);
|
||||
await page.locator('.dashboard3-value dd').evaluateAll((nodes, text) => {
|
||||
if (nodes.length !== text.length) throw new Error('Equivalent reference row count mismatch');
|
||||
nodes.forEach((node, index) => { node.textContent = text[index]; });
|
||||
}, values);
|
||||
if (scenario.settings) {
|
||||
await page.locator('.dashboard3-settings-toggle').click();
|
||||
await page.locator('.dashboard3-settings-dialog__window').waitFor({ state: 'visible' });
|
||||
}
|
||||
record.reference = await page.evaluate(() => {
|
||||
const box = (selector) => document.querySelector(selector)?.getBoundingClientRect().toJSON();
|
||||
return { panel: box('#dashboard3-panel'), control: box('.dashboard5-summary-control'),
|
||||
dialog: box('.dashboard3-settings-dialog__window'), normalizedValueText: [...document.querySelectorAll('.dashboard3-value dd')].map((node) => node.textContent) };
|
||||
});
|
||||
await shot('reference', 'viewport');
|
||||
if (scenario.settings) await shot('reference', 'dialog', page.locator('.dashboard3-settings-dialog__window'));
|
||||
else {
|
||||
await shot('reference', 'panel', page.locator('#dashboard3-panel'));
|
||||
await shot('reference', 'control', page.locator('.dashboard5-summary-control'));
|
||||
}
|
||||
await fixture.assertClean();
|
||||
record.pageErrors = fixture.errors; record.externalRequests = fixture.externalRequests;
|
||||
record.websocketAttempts = fixture.websocketAttempts;
|
||||
report.captures.push(record); saveReport();
|
||||
} finally { await fixture.close(); }
|
||||
}
|
||||
|
||||
try {
|
||||
await probe();
|
||||
const cases = [...panelCases, ...settingCases, ...edgeCases];
|
||||
if (options.only) for (const name of options.only) assert.ok(cases.some((scenario) => scenario.name === name), `Unknown case ${name}`);
|
||||
if (!options.probeOnly) for (const scenario of cases.filter((scenario) => !options.only || options.only.includes(scenario.name))) await capture(scenario);
|
||||
report.status = 'complete'; saveReport();
|
||||
console.log(`Diagnostic evidence: ${join(options.output, 'report.json')}`);
|
||||
} catch (error) {
|
||||
report.status = 'failed'; report.error = error.stack || String(error); saveReport(); throw error;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/** Local-only visual proof for #275; external backup contents are never printed. */
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const input = process.argv[2];
|
||||
const outputDir = resolve(process.argv[3] || '.');
|
||||
const mode = process.argv.includes('--optimized') ? 'optimized' : 'raw';
|
||||
const nodesArg = process.argv.find((value) => value.startsWith('--nodes='))?.slice(8) || '';
|
||||
const nodes = nodesArg.split(';').filter(Boolean).map((pair) => pair.split(',').map(Number));
|
||||
if (!input) {
|
||||
console.error('usage: node demo/capture_wall_strip_backup.mjs <backup> <outdir> '
|
||||
+ '[--optimized] [--nodes=x,y;x,y]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const backup = JSON.parse(readFileSync(input, 'utf8'));
|
||||
const payload = backup?.payload && typeof backup.payload === 'object' ? backup.payload : backup;
|
||||
const source = {
|
||||
config: payload?.config && typeof payload.config === 'object' ? payload.config : payload,
|
||||
layout: payload?.layout && typeof payload.layout === 'object' ? payload.layout : {},
|
||||
};
|
||||
const rendered = mode === 'optimized'
|
||||
? optimizePlans(source.config, source.layout)
|
||||
: source;
|
||||
const config = rendered.config;
|
||||
const layout = rendered.layout;
|
||||
if (!Array.isArray(config?.spaces) || !config.spaces.length) {
|
||||
throw new Error('backup has no spaces');
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const { page, browser } = await launch({ width: 1800, height: 1250 }, 1);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(async ({ cfg, lay }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = structuredClone(lay);
|
||||
card._space = cfg.spaces[0].id;
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._physicalBodiesCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card._isoGeometryCache.clear();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card._fitAll();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
}, { cfg: config, lay: layout });
|
||||
|
||||
const fullPath = resolve(outputDir, `${mode}-full.png`);
|
||||
await page.screenshot({ path: fullPath, animations: 'disabled' });
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const center = await page.evaluate(([x, y]) => {
|
||||
const card = window.__card;
|
||||
const svg = (card.shadowRoot || card.renderRoot).querySelector('.stage svg');
|
||||
const matrix = svg?.getScreenCTM?.();
|
||||
if (!matrix) return null;
|
||||
const point = new DOMPoint(x * 1000, y * 1000).matrixTransform(matrix);
|
||||
return { x: point.x, y: point.y };
|
||||
}, nodes[index]);
|
||||
if (!center) continue;
|
||||
const width = 420, height = 360;
|
||||
const clip = {
|
||||
x: Math.max(0, Math.min(1800 - width, center.x - width / 2)),
|
||||
y: Math.max(0, Math.min(1250 - height, center.y - height / 2)),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
await page.screenshot({
|
||||
path: resolve(outputDir, `${mode}-node-${index + 1}.png`),
|
||||
clip,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ mode, full: fullPath, crops: nodes.length }));
|
||||
@@ -12,6 +12,7 @@ const OPENING_COUNT = 100;
|
||||
const PARTITION_COUNT = 60;
|
||||
const COLUMN_COUNT = 40;
|
||||
const DECOR_COUNT = 500;
|
||||
const STAIR_COUNT = 250;
|
||||
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
@@ -119,6 +120,23 @@ const makeDecor = (floor, count) => Array.from({ length: count }, (_, index) =>
|
||||
};
|
||||
});
|
||||
|
||||
const makeStairs = (floor, count) => Array.from({ length: count }, (_, index) => {
|
||||
const column = index % 25;
|
||||
const row = Math.floor(index / 25);
|
||||
const common = {
|
||||
id: `perf-stair-${floor}-${index}`,
|
||||
x: round(0.025 + column * 0.039),
|
||||
y: round(0.03 + row * 0.1),
|
||||
angle: (index % 8) * 45,
|
||||
target_space_id: 'perf-floor-2',
|
||||
};
|
||||
return index % 2 === 0
|
||||
? { ...common, kind: 'straight', direction: index % 4 ? 'forward' : 'backward',
|
||||
length: 0.12, width: 0.045 }
|
||||
: { ...common, kind: 'spiral', direction: index % 4 ? 'clockwise' : 'counterclockwise',
|
||||
radius: 0.05 };
|
||||
});
|
||||
|
||||
const entityKinds = [
|
||||
['light', 'on'],
|
||||
['switch', 'off'],
|
||||
@@ -188,9 +206,10 @@ export const LARGE_HOUSE_COUNTS = Object.freeze({
|
||||
partitions: PARTITION_COUNT,
|
||||
columns: COLUMN_COUNT,
|
||||
decor: DECOR_COUNT,
|
||||
stairs: STAIR_COUNT,
|
||||
});
|
||||
|
||||
export const makeLargeHouseFixture = () => {
|
||||
export const makeLargeHouseFixture = ({ includeStairs = true } = {}) => {
|
||||
let openingsLeft = OPENING_COUNT;
|
||||
let partitionsLeft = PARTITION_COUNT;
|
||||
let columnsLeft = COLUMN_COUNT;
|
||||
@@ -228,6 +247,7 @@ export const makeLargeHouseFixture = () => {
|
||||
partitions: makePartitions(floor, rooms, partitionCount),
|
||||
wall_columns: makeColumns(floor, rooms, columnCount),
|
||||
decor: makeDecor(floor, decorCount),
|
||||
...(includeStairs && floor === 0 ? { stairs: makeStairs(floor, STAIR_COUNT) } : {}),
|
||||
};
|
||||
});
|
||||
const runtime = makeRuntime(spaces);
|
||||
|
||||
+56
-29
@@ -25,9 +25,10 @@ radial spokes visible instead of hiding them under a translucent room fill.
|
||||
- `verify` requires every image plus a matching matrix manifest and fails on
|
||||
missing/different/error scenarios, browser mismatch or a baseline whose hash
|
||||
no longer matches the reviewed manifest.
|
||||
- `accept` requires `--reviewed`, a complete candidate report and current
|
||||
source fingerprint. It validates the whole set before copying anything and
|
||||
is the only command allowed to update baselines.
|
||||
- `accept` requires `--reviewed`, a complete candidate report, current source
|
||||
fingerprint and one explicit source of provenance: a GitHub Linux capture or
|
||||
the self-hashed WSL passport described below. It validates the whole set
|
||||
before copying anything and is the only command allowed to update baselines.
|
||||
- `scripts/golden-accept.mjs` wraps `accept` and additionally requires the
|
||||
reviewer to declare intent, with two separate flags because they assert two
|
||||
different things. `--expect-change=<id,id>` means "I know why this existing
|
||||
@@ -54,7 +55,7 @@ changes no golden frame; the version-mismatch scenarios keep their own
|
||||
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
|
||||
product never sets the seam (`src/card-version.ts`).
|
||||
|
||||
## Workflow
|
||||
## Diagnostic workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
@@ -65,10 +66,16 @@ npm run golden:capture
|
||||
```
|
||||
|
||||
Review `artifacts/golden/actual/` and, when existing references are present,
|
||||
`artifacts/golden/diff/`. If every image is intentional:
|
||||
`artifacts/golden/diff/`. A plain local capture is diagnostic and cannot be
|
||||
accepted. To update references use one of the two reviewed-source workflows
|
||||
below.
|
||||
|
||||
The CI-artifact path remains unchanged. Download and unpack the complete
|
||||
`golden-images` artifact of a Validate run, review every declared frame, then:
|
||||
|
||||
```bash
|
||||
node scripts/golden-accept.mjs --reviewed --expect-change=wall-junctions-plan-t-dark
|
||||
npm run golden:accept -- --reviewed --from=<unpacked-golden-images> \
|
||||
--expect-change=wall-junctions-plan-t-dark
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
@@ -76,41 +83,61 @@ Never accept images merely to make CI green. A matrix/framing change increments
|
||||
`GOLDEN_MATRIX_VERSION`; a normal rendering fix does not. The first canonical
|
||||
Linux baseline was reviewed and accepted during the v1.60.3-beta.1 gate.
|
||||
|
||||
## Capturing candidates without a second CI round trip (#334)
|
||||
## Attested WSL acceptance with one GitHub round trip (#641)
|
||||
|
||||
Accepting from the `golden-images` CI artifact still works and is still the
|
||||
safest route: unpack it and pass `--from=...`. It costs two full CI runs per
|
||||
visual fix, though — one to produce the artifact and one to verify the accepted
|
||||
baseline — and at matrix version 48 that toll is paid often.
|
||||
|
||||
A local capture is admissible instead, because admissibility is now *proved*
|
||||
rather than assumed. Desktop font rasterisation can differ from the runner, but
|
||||
it cannot differ quietly: it would move every text-bearing scenario, not only
|
||||
the ones under edit. So the rule is simply that the capture must reproduce every
|
||||
accepted baseline the reviewer did not intend to change:
|
||||
A local capture is admissible only through the repository's WSL/ext4 clone,
|
||||
because admissibility is now *proved* rather than assumed. Publish the named
|
||||
issue branch first and leave its worktree clean. The command refuses native
|
||||
Windows, `/mnt/c`, a detached/dirty/unpublished SHA, pin drift and a stale build;
|
||||
it then captures the complete current matrix and refuses undeclared changes,
|
||||
missing frames or an insufficient byte-identical witness floor:
|
||||
|
||||
```bash
|
||||
node scripts/golden-container.mjs # capture in the pinned image
|
||||
node scripts/golden-accept.mjs --reviewed --expect-change=<the scenarios you changed>
|
||||
cd ~/houseplan-card
|
||||
git fetch origin
|
||||
git switch issue/<NN>-<slug>
|
||||
git pull --ff-only origin issue/<NN>-<slug>
|
||||
npm run golden:wsl:capture -- --expect-change=<the scenarios you changed>
|
||||
|
||||
# Review artifacts/golden/actual and diff, then use exactly the same intent.
|
||||
npm run golden:accept -- --reviewed --from=artifacts/golden \
|
||||
--expect-change=<the scenarios you changed>
|
||||
npm run golden:verify
|
||||
```
|
||||
|
||||
If the environment is not pixel-equivalent, unrelated scenarios come out
|
||||
`different`, the wrapper names them and refuses. A wrong container tag or a
|
||||
mismatched font set therefore cannot corrupt baselines — it can only fail.
|
||||
The capture writes `artifacts/golden/wsl-attestation.json`. Its self-hash binds
|
||||
repository/branch/commit/tree and remote SHA, WSL distro/kernel/architecture and
|
||||
filesystem, Node/npm/Playwright/Chromium identity, `package-lock.json`, source
|
||||
fingerprint, matrix version, every scene's dimensions and PNG checksum, the
|
||||
report checksum, witness count/floor and the declared acceptance intent. The
|
||||
accept command verifies the same facts again before writing and stores the
|
||||
local provenance separately in `baselines-index.json`; it never pretends that a
|
||||
local capture came from GitHub Actions.
|
||||
|
||||
`scripts/golden-container.mjs` derives the image tag from the `playwright`
|
||||
version locked in `package-lock.json`, so the container Chromium equals the
|
||||
runner's; `--image=` overrides it when a distro-specific tag is needed
|
||||
(`...:v1.62.0-jammy`). The host `node_modules` is shadowed by an anonymous
|
||||
volume: the repository copy may be built for Windows, and `npm ci` inside the
|
||||
container would otherwise replace it with Linux binaries. The run does write
|
||||
`dist/` and the three bundle copies, exactly as a local `npm run bundle:sync`
|
||||
would.
|
||||
The command prints the exact terminal trailer for the baseline commit:
|
||||
|
||||
Docker is not a requirement of the rule, only a convenience: any Linux
|
||||
environment that satisfies the parity condition qualifies, WSL included. The
|
||||
`chromium` string recorded in the manifest keeps the browser build itself
|
||||
pinned, and `golden:verify` rejects a manifest captured by a different build.
|
||||
```text
|
||||
Release: vX.Y.Z-beta.N
|
||||
Baseline-Reviewed-Local: sha256:<wsl-attestation hash>
|
||||
```
|
||||
|
||||
Use either that local trailer or the existing `Baseline-Reviewed: <GitHub run
|
||||
URL>`, never both. The local digest must match
|
||||
`baselines-index.json.localAttestation.sha256`, which the commit hook and CI
|
||||
provenance job verify. Push the accepted baseline commit and wait for a full
|
||||
GitHub Validate on that exact SHA before S7/merge/release. That final run
|
||||
captures and checks the matrix independently; the WSL path removes only the
|
||||
earlier expected-red artifact-transport run.
|
||||
|
||||
`scripts/golden-container.mjs` and plain `golden:capture` remain useful local
|
||||
diagnostics, but their output has no WSL attestation and therefore cannot be
|
||||
accepted. If the environment is not pixel-equivalent, unrelated scenarios come
|
||||
out `different` anyway; a mismatched font set or browser can only fail.
|
||||
|
||||
`scripts/golden-accept.mjs` deliberately wraps `demo/golden/accept.mjs` instead
|
||||
of replacing its checks: every `.mjs` under `demo/golden` belongs to
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user