mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
infra: idempotent review controller, verdict reuse, single mutant build, current docs
Review pipeline (process.yml): - concurrency moves from the workflow to the guard/review jobs and the guard runs only for S4-spec-review / S7-code-review. Any other label used to enter the issue's concurrency group and evict the pending review run (sample of 150 runs since 2026-09-01: 92 empty guard-only runs, 30 cancelled). - the guard reads the issue's current labels instead of the event snapshot; a label removed before the run starts is a withdrawn request, no comment. - a green verdict is re-applied without calling the model when the latest review document carries the pipeline-recorded verdict `green`/High 0 and the tree differs from its anchor in nothing outside docs/reviews/** (#437 r4 re-reviewed an unchanged tree for 7 minutes). The verdict from structured_output is now written into the anchor block for that purpose. - the reviewer is pinned to the captured material SHA in the prompt; the broken escaping in the "merge cancelled" comment (empty SHAs) is fixed. Mutation gate: nine browser guards started with `npm run bundle:sync` although the runner already builds the mutant bundle — a second rollup plus a `tsc --noEmit` that fails on a non-strict mutant before the smoke even runs. Prefix removed; `--check` refuses guards that build the bundle themselves. Docs: SCOPE (Project v2 dropped, three editors), STATUS (#437 merged, HACS zip automated), USER-GUIDE ru/en (static card shows live states; kiosk double tap on free background fits all), #34 → #425 references, #367 named as closed in bundle-budget messages, PROCESS §10.4 and AGENTS.md describe the controller. Issue: #499 User-Visible: no
This commit is contained in:
@@ -21,10 +21,12 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
# Два события по одному issue не должны запускать два прогона.
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в
|
||||
# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки,
|
||||
# которую переставил сам конвейер. GitHub держит в группе один идущий и один
|
||||
# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review
|
||||
# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает
|
||||
# ни одной job (`if` на guard) и в группу не входит.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -36,7 +38,13 @@ permissions:
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
|
||||
# события помечаются skipped и не занимают место в группе concurrency.
|
||||
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
stage: ${{ steps.decide.outputs.stage }}
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
@@ -57,12 +65,20 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
LABEL: ${{ github.event.label.name }}
|
||||
BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }}
|
||||
EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }}
|
||||
SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }}
|
||||
TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Контроллер идемпотентен (#499): событие только будит его, а состояние
|
||||
# читается ТЕКУЩЕЕ, не из снимка события. Прогон мог простоять в
|
||||
# очереди concurrency, пока владелец снял метку или поставил blocked —
|
||||
# снимок события об этом не знает, а исполнять отозванный запрос нельзя.
|
||||
current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name')
|
||||
has() { printf '%s\n' "$current" | grep -qx -- "$1"; }
|
||||
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
|
||||
has blocked && BLOCKED=true
|
||||
has review-4 && EXHAUSTED=true
|
||||
has small && SMALL=true
|
||||
has trivial && TRIVIAL=true
|
||||
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
case "$LABEL" in
|
||||
@@ -70,6 +86,13 @@ jobs:
|
||||
S7-code-review) stage="code"; marker="CODE-REVIEW" ;;
|
||||
*) echo "метка $LABEL конвейер не запускает" ;;
|
||||
esac
|
||||
# Метка, породившая событие, уже снята — запрос отозван. Это не отказ
|
||||
# и не повод для комментария: владелец передумал сам, шум ему не нужен.
|
||||
if [ -n "$stage" ] && ! has "$LABEL"; then
|
||||
echo "метка $LABEL уже снята с issue — запрос отозван, конвейер не запускается (#499)"
|
||||
echo "запрос отозван: \`$LABEL\` снята до старта" >> "$GITHUB_STEP_SUMMARY"
|
||||
stage=""
|
||||
fi
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
limit=4
|
||||
@@ -236,6 +259,9 @@ jobs:
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -391,6 +417,28 @@ jobs:
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
|
||||
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
|
||||
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
|
||||
# в S7, и ревьюер двенадцать минут заново разбирал дерево, в котором с r3
|
||||
# изменился ровно один файл — его собственный документ r3. Правило узкое:
|
||||
# последний документ этапа несёт записанный конвейером вердикт `green`
|
||||
# с High 0, и `git diff` между его якорем-деревом и HEAD пуст вне
|
||||
# docs/reviews/**. Любое иное отличие — ребейз, тест, фикстура, скрипт,
|
||||
# ТЗ — даёт полный разбор. Только этап code: материал spec может жить в
|
||||
# теле issue, которого в дереве нет.
|
||||
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
|
||||
id: reuse
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD)
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
|
||||
echo "вердикт прошлого захода применяется повторно: модель не вызывается" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона
|
||||
# метка меняется всегда» при этом держится: возврат в S6-in-progress —
|
||||
# тоже смена метки, и автор не ждёт впустую.
|
||||
@@ -464,21 +512,21 @@ jobs:
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -488,7 +536,7 @@ jobs:
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
@@ -573,7 +621,14 @@ jobs:
|
||||
решаешь по существу в своём вердикте.
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет,
|
||||
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
|
||||
`${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не
|
||||
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
|
||||
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
|
||||
ним. Если автор в issue называет более новый коммит, которого в
|
||||
материале нет, — это находка «материал не был запушен до метки», а не
|
||||
повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так,
|
||||
#499). Ручного тестирования в цикле нет,
|
||||
поэтому именно ты отвечаешь на вопрос «оно вообще работает».
|
||||
По каждому AC: либо он доказан автотестом и ты убедился, что тест
|
||||
умеет падать, либо разобран по коду с явной записью «проверено
|
||||
@@ -704,7 +759,7 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
@@ -715,7 +770,12 @@ jobs:
|
||||
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
|
||||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
|
||||
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
|
||||
# Ветки задачи может не быть: у задач, размеченных до появления
|
||||
# конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча
|
||||
# выходил с нулём, и разбор ревью терялся — оставался только вердикт
|
||||
@@ -770,7 +830,8 @@ jobs:
|
||||
# может; блок помечен как машинный, чтобы никто не правил его руками.
|
||||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||||
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS"
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
|
||||
--verdict="$verdict" --high="$high"
|
||||
else
|
||||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||||
fi
|
||||
@@ -860,7 +921,7 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -881,9 +942,26 @@ jobs:
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
REUSE_DOC: ${{ steps.reuse.outputs.doc }}
|
||||
REUSE_ROUND: ${{ steps.reuse.outputs.round }}
|
||||
REUSE_TREE: ${{ steps.reuse.outputs.tree }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
if [ "$REUSE" = "true" ]; then
|
||||
# Модель не вызывалась: вердикт — записанный конвейером зелёный
|
||||
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
|
||||
verdict=green; high=0
|
||||
short_tree=$(printf '%s' "$REUSE_TREE" | cut -c1-12)
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body \
|
||||
"Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC}
|
||||
|
||||
Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор."
|
||||
else
|
||||
verdict=$(echo "$OUT" | jq -r '.verdict')
|
||||
high=$(echo "$OUT" | jq -r '.high')
|
||||
fi
|
||||
echo "вердикт: $verdict, High: $high"
|
||||
|
||||
# Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают
|
||||
@@ -975,9 +1053,9 @@ jobs:
|
||||
cat > /tmp/stale-verdict.md <<EOF
|
||||
**Слияние отменено: ветка изменилась после проверенного материала (#312).**
|
||||
|
||||
Ревью выполнялось на \\`$(git rev-parse --short "$reviewed")\\`, а вершина ветки сейчас \\`$(git rev-parse --short "$actual")\\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
Ревью выполнялось на \`$(git rev-parse --short "$reviewed")\`, а вершина ветки сейчас \`$(git rev-parse --short "$actual")\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.
|
||||
|
||||
Задача переведена в \\`S6-in-progress\\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \\`S7-code-review\\` — новый заход ревью проверит актуальный код.
|
||||
Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/stale-verdict.md
|
||||
exit 0
|
||||
|
||||
@@ -158,7 +158,16 @@ remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
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`. The second review run is not a formality — after a rebase onto a
|
||||
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.
|
||||
|
||||
|
||||
+13
-1
@@ -626,7 +626,7 @@ python -m pytest tests_backend -q # py3.13, если менялся бэке
|
||||
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
|
||||
`any` в 49 файлах; перетипизировать это одним заходом — месяц риска ради нуля
|
||||
пользовательской ценности, поэтому долг снимается при плановом извлечении
|
||||
подсистем (#34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
|
||||
подсистем (#425, прежний #34), а не разовой заменой. Гейт `scripts/no-new-any.mjs` судит
|
||||
**только добавленные строки**: существующий долг на нетронутой строке законен,
|
||||
правка строки со старым `any` — новая ответственность. Исключение объявляется на
|
||||
той же строке, `// any-ok: <конкретная причина>`; голый маркер и причины вида
|
||||
@@ -942,6 +942,18 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
|
||||
сообщать владельцу, а не продолжать опрос.
|
||||
|
||||
**Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard
|
||||
читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди,
|
||||
пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом
|
||||
нет. Конвейер запускают только `S4-spec-review` и `S7-code-review`; остальные метки
|
||||
не создают ни одной job и не входят в concurrency-группу issue — прежде любая
|
||||
посторонняя метка вытесняла ожидающий запуск ревью. Зелёный вердикт применяется
|
||||
повторно **без вызова модели**, если последний документ этапа несёт записанный
|
||||
конвейером вердикт `green` с High 0 и дерево материала не изменилось ни в одном
|
||||
файле вне `docs/reviews/**` (сравнивает `git diff` по содержимому). Ребейз, правка
|
||||
теста, фикстуры или ТЗ дают отличие дерева и полный разбор — правило §7.2 не
|
||||
ослабляется, оно просто не касается дерева, которое уже читали.
|
||||
|
||||
Цикл считается **по этапу**: вердикт по ТЗ не расходует бюджет код-ревью. Раньше
|
||||
считались все вердикты подряд, и первое код-ревью #89 получило `r2/4`.
|
||||
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@
|
||||
built, improved and accepted **only** if they serve a job listed here. When a new
|
||||
idea appears, first find its row in this file; if there is none — it belongs to
|
||||
HA core, to another card, or nowhere. The current order of work lives only in
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
|
||||
[Project v2](https://github.com/users/Matysh/projects/1). Companion
|
||||
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and their
|
||||
status labels (Project v2 was dropped on 2026-08-14, #139). Companion
|
||||
documents: ROADMAP.md (historical engineering direction), UX-MODES.md
|
||||
(interaction model).
|
||||
TOUCH-SUPPORT.md fixes the input-support contract: touch is a guaranteed View
|
||||
@@ -25,7 +25,7 @@ obvious" is somebody else's job.
|
||||
|
||||
| Persona | Role | Surface |
|
||||
|---|---|---|
|
||||
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (both editors live here) |
|
||||
| **Home admin** (primary) | HA enthusiast, house/large flat, 20–200 devices, several floors; sets up and maintains the plan | Desktop browser (all editors live here) |
|
||||
| **Household members** | Non-technical; consume the plan daily, never edit | Wall tablet (kiosk), phone (companion app) |
|
||||
| **Guests / kiosk** | View-only glance at the home | Wall tablet |
|
||||
|
||||
|
||||
+3
-3
@@ -22,12 +22,12 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.73.0-beta.6** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||
| Current local cycle | Development after v1.73.0-beta.6 adds the configurable read-only summary overlay (#437): shared versioned blocks and sources, per-user/per-card show and View sizes, native mobile/fit hiding, authoritative device and clean-floor totals, and a lazy settings form. The feature is still on its issue branch pending code review; publish from `dev` only after exact-SHA Validate is green, while `main` remains at v1.72.0. |
|
||||
| Current local cycle | Development after v1.73.0-beta.6 adds the configurable read-only summary overlay (#437): shared versioned blocks and sources, per-user/per-card show and View sizes, native mobile/fit hiding, authoritative device and clean-floor totals, and a lazy settings form. The feature passed code review and is merged into `dev` (S8-merged, awaiting the next beta); publish from `dev` only after exact-SHA Validate is green, while `main` remains at v1.72.0. |
|
||||
| Hidden Alpha Stage | #89 Stage 1 ships in v1.63.0-beta.1, #122 Stage 2 in v1.64.0 and #160 Stage 3 in v1.73.0-beta.1. The same hidden `iso` view uses the fixed 4° camera, raised/tethered device-room-lock overlays, deeper openings and bounded theme materials; #471 removes the overlay plates from paint while retaining their safety geometry. Since #448 the experiment is enabled only through the single indefinite browser-local `hp_alpha` gate; it is not expiring and has no per-stage key. Flat remains default; editors, `houseplan-space-card`, floor effects, stored coordinates and HA actions remain unchanged. Stage 3 stays internal and is absent from public changelog/user documentation. |
|
||||
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
|
||||
| HACS | **In the default catalog since 2026-08-25** (hacs/default#9004 merged). Install = plain HACS search. Post-merge checklist: run the manual zip workflow on the next stable tag after merging to main; forum/4pda announcement |
|
||||
| HACS | **In the default catalog since 2026-08-25** (hacs/default#9004 merged). Install = plain HACS search. `houseplan.zip` is attached to stable tags automatically (verified on v1.72.0); forum/4pda announcement still pending |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
|
||||
| Localization | UI en/ru/de (src/i18n/*.json), everything user-visible localized incl. kiosk popover; German is loaded lazily through the registry introduced by #62 |
|
||||
| Furniture | #159 replaces the flat ~30-item picker with a two-level category/variant palette and 56 top-view symbols. The reviewed 77-SVG MIT source pack is vendored under `assets/furniture/houseplan-0.3.0`; plan art stays in the initial View graph, front-view category art stays in the lazy editor graph, and existing saved geometry is unchanged. |
|
||||
@@ -40,7 +40,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Current feature surface (since the 2026-07-17 snapshot)
|
||||
|
||||
- **Configurable summary overlay** (#437, current issue branch): a two-part
|
||||
- **Configurable summary overlay** (#437, merged into `dev`): a two-part
|
||||
View control opens one adaptive form or toggles a browser-local preference.
|
||||
Shared ordered blocks can show readable HA entity states and three system
|
||||
values; local icon/text scaling applies to ordinary and kiosk View without
|
||||
|
||||
+1
-1
@@ -930,7 +930,7 @@ node scripts/no-new-any.mjs --diff patch.diff # или `-` для std
|
||||
называл аудит (330), потому что монолит с тех пор разделился и его обвязка
|
||||
уехала в `houseplan-editor-runtime.ts`. Разовая замена такого объёма — месяц
|
||||
риска ради нуля пользовательской ценности, поэтому долг снимается при плановом
|
||||
извлечении подсистем (#34). Гейт держит приращение на нуле.
|
||||
извлечении подсистем (#425, прежний #34). Гейт держит приращение на нуле.
|
||||
|
||||
Что он судит: **только добавленные строки** диапазона. Существующий `any` на
|
||||
нетронутой строке законен. Правка строки со старым `any` считается новой
|
||||
|
||||
+2
-2
@@ -242,7 +242,7 @@ View is the state with no editor open. Close the active editor to return to it.
|
||||
| Device | Draggable; click opens settings | Read-only background | Read-only background |
|
||||
| Background | Dimmed and inert | Dimmed | Background objects editable |
|
||||
| Kiosk | Actionable as in View | Read-only | Read-only; no editors |
|
||||
| Static card | Not live or interactive | Render only | Render only |
|
||||
| Static card | Live states, values and alarms as in View (`live_states`), no tooltips or more-info | Render only | Render only; not interactive |
|
||||
|
||||
### Summary panel
|
||||
|
||||
@@ -964,7 +964,7 @@ contract.
|
||||
## 17. Kiosk
|
||||
|
||||
Set `kiosk: true` on a card in a Panel view. Editors and the ordinary header are
|
||||
removed. Pinch/drag navigate, double-tap resets, and a 1:1 horizontal swipe
|
||||
removed. Pinch/drag navigate, double-tap on free background fits the whole plan, and a 1:1 horizontal swipe
|
||||
changes space. Holding empty space for three seconds opens per-display icon and
|
||||
text sizing. `cycle` enables automatic space changes; interaction pauses it for
|
||||
60 seconds.
|
||||
|
||||
@@ -256,7 +256,7 @@ title: План дома
|
||||
| Редактор устройств | Позиции и параметры маркеров | Перетаскиваются; клик открывает настройки; доступны скрытые маркеры | Не редактируются | Служат фоном |
|
||||
| Редактор подложки | Изображение, линии, фигуры, текст, мебель | Не редактируются и полупрозрачны | Комнаты и все стены полупрозрачны | Декор интерактивен и непрозрачен; скрытый в просмотре декор всё равно виден здесь |
|
||||
| Киоск | Настенный экран | Интерактивны как в просмотре | Только отображение | Только отображение; редакторы отсутствуют |
|
||||
| Статическая карточка | Компактная схема пространства | Только рисунок | Только рисунок | Без состояний и интерактивности |
|
||||
| Статическая карточка | Компактная схема пространства | Живые состояния, значения и тревоги как в просмотре (`live_states`), без подсказок и more-info | Только рисунок | Без интерактивности; см. §18 |
|
||||
|
||||
### Сводная панель
|
||||
|
||||
@@ -1706,7 +1706,7 @@ cycle: 30
|
||||
| Высота | 100dvh |
|
||||
| Свайп | Листает пространства по кругу при масштабе 1:1 |
|
||||
| Щипок/панорама | Масштабирует и двигает план |
|
||||
| Двойной тап | Сбрасывает масштаб к 1:1 |
|
||||
| Двойной тап по свободному фону | Вписывает весь план (как «Вписать всё») |
|
||||
| Удержание пустого места 3 секунды | Открывает локальные размеры иконок и текста |
|
||||
| `cycle` | Автоматически листает пространства; при взаимодействии пауза 60 секунд |
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ export function initialViewCeilingViolation(bytes, {
|
||||
over: bytes - ceiling,
|
||||
text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
+ ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый'
|
||||
+ ' граф (#367). Молча расти этому графу больше нечем.',
|
||||
+ ' граф (история: #367 → #474). Молча расти этому графу больше нечем.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
@@ -268,7 +268,7 @@ export function initialViewCeilingViolation(bytes, {
|
||||
under: ceiling - bytes,
|
||||
text: `initial View graph ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш граф`
|
||||
+ ' отыграет обратно, и это уже происходило (#367).',
|
||||
+ ' отыграет обратно, и это уже происходило (#367, закрыт).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -290,10 +290,11 @@ export function lowHeadroomWarning(headroom, {
|
||||
? ` Признание долга покрывает потолок до ${acknowledgedCeiling} Б, а он уже ${ceiling} Б —`
|
||||
+ ' граф вырос с тех пор, вопрос вернулся.'
|
||||
: ' Погасить можно решением владельца: LOW_HEADROOM_ACKNOWLEDGED_CEILING в'
|
||||
+ ' scripts/bundle-budget.mjs. Пока не погашено — это долг (#367).';
|
||||
+ ' scripts/bundle-budget.mjs. Живого issue у долга нет: #367 закрыт рекалибровкой,'
|
||||
+ ' его рычаг сделан в #474 (#499) — следующий рост заводит новый.';
|
||||
return `запас бюджета ${headroom} Б, меньше порога ${threshold} Б:`
|
||||
+ ' следующая средняя фича упрётся в стену. Рекалибровка это не лечит —'
|
||||
+ ' смотрите ленивые графы (#367).' + stale;
|
||||
+ ' смотрите ленивые графы (история: #367 → #474).' + stale;
|
||||
}
|
||||
|
||||
export function assertBundleBudget(
|
||||
|
||||
@@ -1359,7 +1359,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'vacuum-overlay-back-to-the-dock-space-filter',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_vacuum_multifloor.mjs',
|
||||
guard: 'node demo/smoke_vacuum_multifloor.mjs',
|
||||
because: 'the overlay layer must see every robot of the plan, not only those whose DOCK '
|
||||
+ 'is in the space on screen: the old filter is exactly why a multi-floor robot could '
|
||||
+ 'never appear on its second floor (#162, AC2)',
|
||||
@@ -1383,7 +1383,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'vacuum-manual-fit-after-proposal-uses-the-dock',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_vacuum_multifloor.mjs',
|
||||
guard: 'node demo/smoke_vacuum_multifloor.mjs',
|
||||
because: 'the high-residual proposal is refined against the geometry the matrix was solved '
|
||||
+ 'against — opening it on the dock floor fits the robot to the wrong plan (#162, AC8, '
|
||||
+ 'the stateful half the pure calibrationTarget mutant cannot reach)',
|
||||
@@ -4297,7 +4297,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'near-axis-authoring-snap-bypassed',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_plan_drawing_repairs.mjs',
|
||||
guard: 'node demo/smoke_plan_drawing_repairs.mjs',
|
||||
because: 'the production Walls hover and click must persist the exact same straight endpoint '
|
||||
+ 'instead of merely repairing old data through Optimize (#290)',
|
||||
patches: [{
|
||||
@@ -4331,7 +4331,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'near-axis-optimize-confirmation-bypassed',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_near_axis_optimize.mjs',
|
||||
guard: 'node demo/smoke_near_axis_optimize.mjs',
|
||||
because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-editor-runtime.ts',
|
||||
@@ -6242,7 +6242,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'current-rejected-physical-write-keeps-optimistic-wall',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_v8_draft_write.mjs',
|
||||
guard: 'node demo/smoke_v8_draft_write.mjs',
|
||||
because: 'a rejected config/set must synchronously discard its whole pending physical batch '
|
||||
+ 'before an optimistic active-chain partition survives as a ghost wall (#314/#478)',
|
||||
patches: [{
|
||||
@@ -6253,7 +6253,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'area-relocation-loses-position-on-refusal',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_area_relocation_safety.mjs',
|
||||
guard: 'node demo/smoke_area_relocation_safety.mjs',
|
||||
because: 'a rejected provenance write must restore every manual layout point deleted '
|
||||
+ 'before config/set, or leave explicit attention when restoration also fails (#403)',
|
||||
patches: [{
|
||||
@@ -6264,7 +6264,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'area-relocation-clears-whole-history',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_area_relocation_safety.mjs',
|
||||
guard: 'node demo/smoke_area_relocation_safety.mjs',
|
||||
because: 'moving one marker to a new HA Area must invalidate only that marker\'s commands '
|
||||
+ 'instead of silently erasing Undo and Redo for every other marker (#403)',
|
||||
patches: [{
|
||||
@@ -7305,7 +7305,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'furniture-art-editor-adopt-skipped',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_furniture.mjs',
|
||||
guard: 'node demo/smoke_furniture.mjs',
|
||||
because: 'the editor imports the artwork statically and must hand it over synchronously; without '
|
||||
+ 'adopt the palette previews and the placement ghost render empty on a plan without furniture (#474 r1)',
|
||||
patches: [{
|
||||
@@ -7567,7 +7567,7 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'writer-history-skips-finished-chain-normalization',
|
||||
guard: 'npm run bundle:sync && node demo/smoke_writer_fixed_point.mjs',
|
||||
guard: 'node demo/smoke_writer_fixed_point.mjs',
|
||||
because: 'Undo/Redo after a chain has finished must restore canonical snapshots rather than '
|
||||
+ 'bringing the hidden collinear seams back into durable config (#477)',
|
||||
patches: [{
|
||||
@@ -7737,6 +7737,9 @@ export function guardNeedsTestBuild(guard) {
|
||||
* выполняется только там, где её результат кто-то откроет.
|
||||
*/
|
||||
export function guardNeedsBundle(guard) {
|
||||
// `bundle:sync` в гварде реестр больше не допускает (--check, #499), но
|
||||
// распознавание остаётся: чужой или старый гвард со сборкой всё равно
|
||||
// браузерный, и бандл ему нужен.
|
||||
return guard.includes('demo/') || guard.includes('bundle:sync');
|
||||
}
|
||||
|
||||
@@ -8015,6 +8018,13 @@ function main(argv) {
|
||||
let stale = 0;
|
||||
for (const m of selected) {
|
||||
try {
|
||||
// #499: бандл мутанта собирает раннер (`buildBundle`, только rollup +
|
||||
// sync). Гвард с собственным `npm run bundle:sync` собирал бы его второй
|
||||
// раз — плюс `tsc --noEmit`, который на нестрогом мутанте падает сам и
|
||||
// красит гвард ещё до теста: «мутант пойман» без единого запуска смока.
|
||||
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard)) {
|
||||
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
|
||||
}
|
||||
for (const patch of m.patches) {
|
||||
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
|
||||
const hits = source.split(patch.find).length - 1;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Зачем гейт, а не разовая типизация. В `src/**` сейчас 1034 вхождения явного
|
||||
* `any` в 49 файлах — перетипизировать это одним заходом значит месяц риска ради
|
||||
* нуля пользовательской ценности. Долг снимается при плановом извлечении
|
||||
* подсистем (#34). Задача гейта одна: не давать долгу расти.
|
||||
* подсистем (#425, прежний #34). Задача гейта одна: не давать долгу расти.
|
||||
*
|
||||
* Практический вред уже случался: несоответствие форм (`d.source.kind` против
|
||||
* строкового `source`) компилятор не поймал, потому что путь был через `any`, и
|
||||
|
||||
@@ -252,7 +252,7 @@ export function anchorLiveness(object, run) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function materialAnchorBlock({ sha, tree, branch, specs = [] } = {}) {
|
||||
export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high } = {}) {
|
||||
const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : '');
|
||||
const lines = [
|
||||
ANCHOR_MARKER,
|
||||
@@ -277,6 +277,13 @@ export function materialAnchorBlock({ sha, tree, branch, specs = [] } = {}) {
|
||||
if (!tree && !specs.length) {
|
||||
lines.push('- Якоря снять не удалось: ветки задачи нет, материал читался по `dev`.');
|
||||
}
|
||||
// Вердикт из structured_output модели, записанный конвейером (#499): по нему
|
||||
// следующий заход решает, можно ли применить зелёный вердикт повторно без
|
||||
// вызова модели. Прозу документа для этого читать нельзя — она цитирует
|
||||
// прошлые раунды и пишется в свободной форме.
|
||||
if (verdict) {
|
||||
lines.push(`- Вердикт конвейера: \`${verdict}\` · High ${Number.isFinite(Number(high)) ? Number(high) : '?'}`);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
@@ -430,6 +437,63 @@ export function isBlockingVerdict(line) {
|
||||
return Boolean(line) && BLOCKING_COLOUR.test(line);
|
||||
}
|
||||
|
||||
/** Дерево материала из блока якорей документа (#414), либо `null`. */
|
||||
export function anchorTreeFrom(text) {
|
||||
const body = String(text ?? '');
|
||||
const at = body.indexOf(ANCHOR_MARKER);
|
||||
if (at < 0) return null;
|
||||
const match = body.slice(at).match(/Дерево материала: `([0-9a-f]{40})`/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Вердикт конвейера из блока якорей (#499): `{ verdict, high }` либо `null`. */
|
||||
export function anchorVerdictFrom(text) {
|
||||
const body = String(text ?? '');
|
||||
const at = body.indexOf(ANCHOR_MARKER);
|
||||
if (at < 0) return null;
|
||||
const match = body.slice(at).match(/Вердикт конвейера: `(green|yellow|red)` · High (\d+)/);
|
||||
return match ? { verdict: match[1], high: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторное применение зелёного вердикта без вызова модели (#499).
|
||||
*
|
||||
* Сценарий #437 r4: зелёный r3 не слился (страж #312 не признал вершину), задача
|
||||
* вернулась в S6 и тут же в S7 — и ревьюер двенадцать минут заново разбирал
|
||||
* дерево, в котором с r3 изменился ровно один файл: собственный документ r3.
|
||||
*
|
||||
* Правило узкое и одностороннее: вердикт переиспользуется ТОЛЬКО если
|
||||
* - последний опубликованный документ этапа несёт записанный КОНВЕЙЕРОМ
|
||||
* вердикт `green` с High 0 (из structured_output модели, не из прозы);
|
||||
* - его якорь «дерево материала» снят конвейером (#414), а не написан рукой;
|
||||
* - текущее дерево отличается от якоря НИЧЕМ, кроме docs/reviews/** —
|
||||
* сравнение делает git по содержимому, так что ребейз на ушедший dev,
|
||||
* правка теста, фикстуры, скрипта или ТЗ в docs/specs дают отличие и
|
||||
* полный разбор (§7.2). Смена базы без изменения дерева невозможна:
|
||||
* дерево ветки после ребейза включает содержимое dev.
|
||||
* Любое сомнение — `null`, и ревью идёт как обычно.
|
||||
*
|
||||
* @param docs [{ name, text }] опубликованные документы этапа этой задачи
|
||||
* @param differs (tree) => boolean — есть ли отличие дерева от HEAD вне docs/reviews
|
||||
*/
|
||||
export function reusableGreenVerdict(docs, differs) {
|
||||
const numbered = (docs || [])
|
||||
.map((doc) => ({ ...doc, round: Number((String(doc?.name || '').match(/-r(\d+)\.md$/) || [])[1]) }))
|
||||
.filter((doc) => Number.isFinite(doc.round))
|
||||
.sort((a, b) => b.round - a.round);
|
||||
const latest = numbered[0];
|
||||
if (!latest) return null;
|
||||
// Судится запись конвейера, не проза: документ r4 по #437 держит вердикт
|
||||
// словом «Зелёный.» под заголовком раздела и цитирует r3 строкой «Вердикт
|
||||
// r3: зелёный» — ни то, ни другое не годится как машинный источник.
|
||||
const recorded = anchorVerdictFrom(latest.text);
|
||||
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) return null;
|
||||
const tree = anchorTreeFrom(latest.text);
|
||||
if (!tree) return null;
|
||||
if (differs(tree)) return null;
|
||||
return { doc: latest.name, round: latest.round, tree, verdict: 'green' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокирующие раунды по документам: `{ blocking, unread }`.
|
||||
*
|
||||
@@ -607,6 +671,36 @@ if (invokedDirectly) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Режим повторного применения зелёного вердикта (#499): документы этапа
|
||||
// читаются из рабочей копии (они в дереве ветки), отличие дерева судит git.
|
||||
// --reuse --marker=CODE-REVIEW --num=437 [--head=HEAD]
|
||||
// Печатает `reuse=true|false` и, при true, `doc=`, `round=`, `tree=`, `verdict=`.
|
||||
if (argv.includes('--reuse')) {
|
||||
const value = (name) => {
|
||||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||||
return found ? found.slice(name.length + 3) : '';
|
||||
};
|
||||
const marker = value('marker'); const num = value('num'); const head = value('head') || 'HEAD';
|
||||
const listed = spawnSync('git', ['ls-tree', '--name-only', `${head}:docs/reviews`], { encoding: 'utf8' });
|
||||
const names = (listed.stdout || '').split('\n')
|
||||
.filter((name) => new RegExp(`^${marker}-${num}-r\\d+\\.md$`).test(name));
|
||||
const docs = names.map((name) => {
|
||||
const shown = spawnSync('git', ['show', `${head}:docs/reviews/${name}`], { encoding: 'utf8' });
|
||||
return { name, text: shown.status === 0 ? shown.stdout : '' };
|
||||
});
|
||||
const differs = (tree) => {
|
||||
// Сначала дерево обязано существовать: неизвестный объект — не «совпало».
|
||||
const exists = spawnSync('git', ['cat-file', '-e', `${tree}^{tree}`], { encoding: 'utf8' });
|
||||
if (exists.status !== 0) return true;
|
||||
const diff = spawnSync('git', ['diff', '--quiet', tree, head, '--', '.', ':!docs/reviews'], { encoding: 'utf8' });
|
||||
return diff.status !== 0;
|
||||
};
|
||||
const found = reusableGreenVerdict(docs, differs);
|
||||
if (!found) { process.stdout.write('reuse=false\n'); process.exit(0); }
|
||||
process.stdout.write(`reuse=true\ndoc=${found.doc}\nround=${found.round}\ntree=${found.tree}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Режим дописывания якорей (#414): конвейер снял их при чтении материала.
|
||||
const anchorArg = argv.find((item) => item.startsWith('--anchor='));
|
||||
if (anchorArg) {
|
||||
@@ -620,6 +714,8 @@ if (invokedDirectly) {
|
||||
tree: value('tree'),
|
||||
branch: value('branch'),
|
||||
specs: parseSpecList(value('specs')),
|
||||
verdict: ['green', 'yellow', 'red'].includes(value('verdict')) ? value('verdict') : '',
|
||||
high: value('high'),
|
||||
};
|
||||
const text = readFileSync(path, 'utf8');
|
||||
writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8');
|
||||
|
||||
@@ -449,3 +449,18 @@ test('#481 AC6 (реестр): бамп версии продукта не ме
|
||||
assert.equal(after, before, `${m.id}: релизный бамп изменил отпечаток`);
|
||||
}
|
||||
});
|
||||
|
||||
// #499: девять браузерных гвардов начинались с `npm run bundle:sync` — раннер
|
||||
// уже собрал бандл мутанта, гвард собирал его второй раз, а `tsc --noEmit`
|
||||
// внутри `bundle:sync` красил гвард на нестрогом мутанте ещё до смока.
|
||||
test('#499: ни один гвард реестра не собирает бандл сам — сборка одна, у раннера', () => {
|
||||
const selfBuilding = MUTANTS.filter((m) => /bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard));
|
||||
assert.deepEqual(selfBuilding.map((m) => m.id), []);
|
||||
// Отрицательный свидетель: --check отвергает такой гвард. Проверяется на самой
|
||||
// регулярке из реестра, чтобы правило и тест не расходились.
|
||||
const guard = 'npm run bundle:sync && node demo/smoke_furniture.mjs';
|
||||
assert.ok(/bundle:sync|bundle-sync\.mjs|rollup -c/.test(guard));
|
||||
assert.equal(guardNeedsBundle('node demo/smoke_furniture.mjs'), true, 'без префикса гвард остаётся браузерным');
|
||||
const source = readFileSync(new URL('../scripts/mutation-gate.mjs', import.meta.url), 'utf8');
|
||||
assert.match(source, /гвард сам собирает бандл — сборку делает раннер \(#499\)/);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ANCHOR_MARKER, REVIEW_DOC_ALLOWLIST, anchorLiveness, REVIEW_HEADER_LINES, citedMaterialShas, danglingMaterialRefusal, materialAnchorBlock, materialAnchorsFrom, parseSpecList, pathsOutsideAllowlist, reviewDocPushRefusal, withMaterialAnchors,
|
||||
attemptFromRounds, blockingFromDocs, isBlockingVerdict, reviewCounters, reviewRoundsFromFiles, verdictDeclaration,
|
||||
commentCounters, stageVerdictComments,
|
||||
anchorTreeFrom, anchorVerdictFrom, reusableGreenVerdict,
|
||||
} from '../scripts/review-doc-guard.mjs';
|
||||
|
||||
// #365. 28.08 шаг публикации ревью-дока запушил в dev коммит bb2919f с тридцатью
|
||||
@@ -526,3 +527,77 @@ test('мусор в комментариях не роняет счёт (#454 AC
|
||||
assert.equal(commentCounters([C('Вердикт: жёлтый CODE-REVIEW-454-r1.md')], '', '454').attempt, 1);
|
||||
assert.deepEqual(stageVerdictComments([C('Вердикт: жёлтый CODE-REVIEW-454-r1.md')], 'CODE-REVIEW', 'x'), []);
|
||||
});
|
||||
|
||||
// #499: повторное применение зелёного вердикта без вызова модели — только по
|
||||
// записи конвейера и только при неизменённом дереве вне docs/reviews.
|
||||
|
||||
const TREE_A = 'a'.repeat(40);
|
||||
const TREE_B = 'b'.repeat(40);
|
||||
const docWith = (name, anchors) => ({ name, text: withMaterialAnchors(`# ${name}\n\nВердикт: зелёный · заход r9\n`, anchors) });
|
||||
|
||||
test('блок якорей несёт вердикт конвейера, и он читается обратно (#499)', () => {
|
||||
const block = materialAnchorBlock({ sha: 'c'.repeat(40), tree: TREE_A, branch: 'issue/1-x', verdict: 'green', high: 0 });
|
||||
assert.match(block, /Вердикт конвейера: `green` · High 0/);
|
||||
assert.deepEqual(anchorVerdictFrom(block), { verdict: 'green', high: 0 });
|
||||
assert.equal(anchorTreeFrom(block), TREE_A);
|
||||
// Без вердикта строки нет — старые документы остаются старыми.
|
||||
assert.doesNotMatch(materialAnchorBlock({ tree: TREE_A }), /Вердикт конвейера/);
|
||||
assert.equal(anchorVerdictFrom(materialAnchorBlock({ tree: TREE_A })), null);
|
||||
// Проза вне блока якорей не читается как запись конвейера.
|
||||
assert.equal(anchorVerdictFrom('Вердикт конвейера: `green` · High 0\nбез маркера'), null);
|
||||
});
|
||||
|
||||
test('зелёный r3 с неизменённым деревом применяется повторно (#499)', () => {
|
||||
const docs = [
|
||||
docWith('CODE-REVIEW-437-r1.md', { tree: TREE_B, verdict: 'yellow', high: 0 }),
|
||||
docWith('CODE-REVIEW-437-r3.md', { tree: TREE_A, verdict: 'green', high: 0 }),
|
||||
docWith('CODE-REVIEW-437-r2.md', { tree: TREE_B, verdict: 'yellow', high: 0 }),
|
||||
];
|
||||
const asked = [];
|
||||
const found = reusableGreenVerdict(docs, (tree) => { asked.push(tree); return false; });
|
||||
assert.deepEqual(found, { doc: 'CODE-REVIEW-437-r3.md', round: 3, tree: TREE_A, verdict: 'green' });
|
||||
assert.deepEqual(asked, [TREE_A], 'судится только последний раунд');
|
||||
});
|
||||
|
||||
test('изменённое дерево, жёлтый вердикт, High>0 или проза без записи — полный разбор (#499)', () => {
|
||||
const green = docWith('CODE-REVIEW-1-r2.md', { tree: TREE_A, verdict: 'green', high: 0 });
|
||||
assert.equal(reusableGreenVerdict([green], () => true), null, 'дерево отличается');
|
||||
assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r2.md', { tree: TREE_A, verdict: 'yellow', high: 0 })], () => false), null);
|
||||
assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r2.md', { tree: TREE_A, verdict: 'green', high: 1 })], () => false), null);
|
||||
// Зелёный r2, но поверх него жёлтый r3 — последний решает.
|
||||
assert.equal(reusableGreenVerdict([green, docWith('CODE-REVIEW-1-r3.md', { tree: TREE_A, verdict: 'red', high: 2 })], () => false), null);
|
||||
// Документ без записи конвейера (до #499) — только проза «Вердикт: зелёный».
|
||||
assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r1.md', { tree: TREE_A })], () => false), null);
|
||||
// Якорь дерева отсутствует — сравнивать нечего.
|
||||
assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r1.md', { verdict: 'green', high: 0 })], () => false), null);
|
||||
assert.equal(reusableGreenVerdict([], () => false), null);
|
||||
});
|
||||
|
||||
test('конвейер: посторонняя метка не входит в concurrency, guard читает текущие метки (#499)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
// Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7.
|
||||
const head = workflow.slice(0, workflow.indexOf('\njobs:'));
|
||||
assert.doesNotMatch(head, /^concurrency:/m, 'concurrency на уровне workflow снова пустит в группу все метки');
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n review:'));
|
||||
assert.match(guard, /if: github\.event\.label\.name == 'S4-spec-review' \|\| github\.event\.label\.name == 'S7-code-review'/);
|
||||
assert.match(guard, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/);
|
||||
const review = workflow.slice(workflow.indexOf('\n review:'));
|
||||
assert.match(review, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/);
|
||||
// Состояние читается текущее, не из снимка события.
|
||||
assert.match(guard, /gh issue view "\$NUM" --repo "\$REPO" --json labels/);
|
||||
assert.doesNotMatch(guard, /contains\(github\.event\.issue\.labels/, 'снимок события больше не источник меток');
|
||||
assert.match(guard, /запрос отозван/);
|
||||
});
|
||||
|
||||
test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/);
|
||||
const reviewStep = workflow.slice(workflow.indexOf(' - name: Review\n'), workflow.indexOf('anthropics/claude-code-action'));
|
||||
assert.match(reviewStep, /steps\.reuse\.outputs\.reuse != 'true'/, 'модель не вызывается при повторном применении');
|
||||
const publish = workflow.slice(workflow.indexOf('- name: Опубликовать документ ревью'), workflow.indexOf('- name: Решение по вердикту'));
|
||||
assert.match(publish, /--verdict="\$verdict" --high="\$high"/);
|
||||
const decide = workflow.slice(workflow.indexOf('- name: Решение по вердикту'), workflow.indexOf('- name: dev ушёл вперёд'));
|
||||
assert.match(decide, /if \[ "\$REUSE" = "true" \]; then\n\s+(#[^\n]*\n\s+)*verdict=green; high=0/);
|
||||
// Ревьюер привязан к SHA материала — сам подтягивать новее не должен.
|
||||
assert.match(workflow, /Материал ревью — ровно\s+`\$\{\{ steps\.material\.outputs\.sha \}\}`/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user