mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c9f8e77c | ||
|
|
674e589ad8 | ||
|
|
26763c7ca4 | ||
|
|
52a56430ab | ||
|
|
6e07313460 | ||
|
|
f903e93c83 | ||
|
|
ae0e516af1 | ||
|
|
e7fca7b955 | ||
|
|
48f6f2e6a7 | ||
|
|
c949e0bbc9 | ||
|
|
5bb7197620 | ||
|
|
6a8658cab9 | ||
|
|
258d1990c8 | ||
|
|
2369c50607 | ||
|
|
c68d92f674 | ||
|
|
8bb43fd379 | ||
|
|
310bc6e4f8 | ||
|
|
1aa52d2107 | ||
|
|
f6e317d871 | ||
|
|
a005aae5b6 | ||
|
|
e45bc87c6d | ||
|
|
e1700757de | ||
|
|
1606407107 | ||
|
|
8dcc1cad4e | ||
|
|
224d0106fd | ||
|
|
ee3aa068fc | ||
|
|
19dc61db15 | ||
|
|
c66884b7f5 | ||
|
|
59bf216b60 | ||
|
|
6ab791e348 | ||
|
|
e6dfa9a752 | ||
|
|
5986332eda | ||
|
|
13054e246e |
@@ -12,3 +12,9 @@
|
||||
*.zip binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# #698: записи двух задач в `## Unreleased` друг другу не противоречат — при
|
||||
# слиянии и ребейзе git объединяет обе стороны сам (встроенный драйвер union),
|
||||
# и параллельные задачи не возвращаются в S6 из-за соседней строки ченджлога.
|
||||
docs/CHANGELOG.md merge=union
|
||||
docs/CHANGELOG.ru.md merge=union
|
||||
|
||||
@@ -1152,15 +1152,15 @@ jobs:
|
||||
ограничитель: «features are built, improved and accepted only
|
||||
if they serve a job listed here». Первый вопрос к задаче —
|
||||
какую строку Core user jobs она закрывает.
|
||||
2. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
|
||||
2. AGENTS.md — карта пакета, правило №1, классы изменений, треки,
|
||||
трейлеры и ожидание вердикта; сами правила — по его ссылкам.
|
||||
3. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
|
||||
ревью ТЗ, код-ревью, объём гейтов, повторный раунд, находки и
|
||||
вердикт. Раздел PROCESS.md по ссылке открывай, когда пункт
|
||||
касается твоего решения; при расхождении прав PROCESS.md. Если
|
||||
файла в материале нет — читай PROCESS.md §2.4, §2.7, §2.10,
|
||||
§4, §7.2, §8, §12. Задача правит сам конвейер, гейты или
|
||||
процесс — PROCESS.md целиком, §10 в первую очередь.
|
||||
3. AGENTS.md — карта пакета, правило №1, классы изменений, треки,
|
||||
трейлеры и ожидание вердикта; сами правила — по его ссылкам.
|
||||
4. Тело issue #${{ github.event.issue.number }} и все комментарии.
|
||||
5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
|
||||
терминология интерфейса берётся оттуда, а не изобретается.
|
||||
@@ -1768,10 +1768,13 @@ jobs:
|
||||
# когда кандидат не слит (конфликт, красный Validate, изменившийся
|
||||
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
|
||||
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
|
||||
# #706: исход `rereview` возвращает задачу в ту же метку. Одним вызовом
|
||||
# `gh` она добавлялась и тут же снималась — задача оставалась без
|
||||
# статуса. Скрипт снимает и ставит её заново, чтобы событие `labeled`
|
||||
# запустило новый заход.
|
||||
run: |
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
echo "$FROM -> $TO"
|
||||
node scripts/status-label.mjs --repo="${{ github.repository }}" \
|
||||
--issue="$NUM" --from="$FROM" --to="$TO"
|
||||
|
||||
- name: Сводка длительности стадий
|
||||
if: always()
|
||||
|
||||
@@ -53,12 +53,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||||
# обращается к API сверх содержимого репозитория.
|
||||
# проверка 8 процессного гейта, `issues: write` — одно issue о расхождении
|
||||
# зеркала workflow на push в dev (#700). Права перечислены явно, потому что
|
||||
# job обращается к API сверх содержимого репозитория.
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: read
|
||||
issues: write
|
||||
steps:
|
||||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||||
@@ -81,19 +82,24 @@ jobs:
|
||||
id: docs
|
||||
continue-on-error: true
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
# #697: на ветке задачи `Release:` строгий режим не включает.
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
# #700: на ветке задачи упавший чужой сайт — предупреждение, а не
|
||||
# красный preflight; push в dev, кандидат и релиз судят ссылки строго.
|
||||
external=--external
|
||||
case "$REF" in refs/heads/issue/*) external=--external=warn ;; esac
|
||||
# #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой
|
||||
# `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)`
|
||||
# строки схлопываются через пробел, сравнение не совпадало никогда,
|
||||
# и строгий режим не включился ни на одном кандидате.
|
||||
mode=$(node scripts/classify-changes.mjs --screenshots-mode)
|
||||
echo "скриншоты документации: режим $mode"
|
||||
node scripts/check-docs.mjs --external --screenshots=$mode
|
||||
node scripts/check-docs.mjs "$external" --screenshots=$mode
|
||||
|
||||
# #635: `docs/reviews/INDEX.md` — снимок каталога ревью; расхождение с
|
||||
# каталогом — невидимые через индекс документы (r2 #635 H1). Гейт стоит
|
||||
@@ -140,6 +146,39 @@ jobs:
|
||||
done
|
||||
exit $status
|
||||
|
||||
# #700: расхождение зеркала на dev — одно открытое issue владельцу, как у
|
||||
# ночного мутационного гейта (#472): чинит его тот, кто зеркалит в main, а
|
||||
# не автор задачи, чья ветка ни при чём. Сбой API — предупреждение: шаг
|
||||
# сообщает, а не судит.
|
||||
- name: "Расхождение зеркала на dev — issue владельцу"
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.workflow_sync.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
marker="[workflow-sync]"
|
||||
# Список не прочитан — нового issue нет: иначе сбой сети или
|
||||
# рейт-лимит плодил бы дубликат к уже открытому (r1 #700).
|
||||
if ! existing=$(gh issue list --repo "$REPO" --state open --search "\"$marker\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith("[workflow-sync]"))][0].number // empty'); then
|
||||
echo "::warning::открытые issue не прочитаны — новое не заводится, чтобы не завести дубликат"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" --repo "$REPO" --body "Расхождение повторилось: $RUN_URL" \
|
||||
|| echo "::warning::комментарий в #$existing не оставлен"
|
||||
exit 0
|
||||
fi
|
||||
cat > /tmp/workflow-sync.md <<EOF
|
||||
Тонкие вызывающие workflow в \`main\` и \`dev\` различаются. Прогон: $RUN_URL
|
||||
|
||||
Тонкий файл исполняется из ветки по умолчанию, поэтому правку триггеров, входов или прав нужно зеркалить в \`main\` (PROCESS.md §10.4, #623). На ветках задач это предупреждение (#700), на push в \`dev\` — красный preflight, пока зеркало не выровнено.
|
||||
EOF
|
||||
gh issue create --repo "$REPO" --title "$marker тонкие workflow в main и dev различаются" \
|
||||
--label infra --label process --body-file /tmp/workflow-sync.md \
|
||||
|| echo "::warning::issue о расхождении зеркала не заведено"
|
||||
|
||||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||||
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
|
||||
@@ -220,6 +259,7 @@ jobs:
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
ACTION_PINS: ${{ steps.action_pins.outcome }}
|
||||
REVIEWS_INDEX: ${{ steps.reviews_index.outcome }}
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
@@ -231,9 +271,25 @@ jobs:
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
# #700: причина, к которой ветка задачи отношения не имеет, — в сводку
|
||||
# предупреждением; на dev, кандидате и релизе она красит как прежде.
|
||||
advise() {
|
||||
if [ "$2" = "success" ]; then
|
||||
echo "ok $1"
|
||||
else
|
||||
echo "::warning::$1 ($2) — на ветке задачи предупреждение (#700)"
|
||||
echo "- $1: $2 — предупреждение, чинит релиз-менеджер (#700)" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
}
|
||||
task_branch=false
|
||||
case "$REF" in refs/heads/issue/*) task_branch=true ;; esac
|
||||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||||
check "документация" "$DOCS"
|
||||
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
if [ "$task_branch" = "true" ]; then
|
||||
advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
else
|
||||
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
|
||||
fi
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
check "пины сторонних Actions" "$ACTION_PINS"
|
||||
|
||||
@@ -29,7 +29,9 @@ leak interactions into View. For work that changes visible behaviour, also read
|
||||
route and `test/entry-cost.test.mjs` keeps this list equal to its routes:
|
||||
|
||||
- author (analysis, spec, implementation, infrastructure): `docs/SCOPE.md` →
|
||||
`AGENTS.md` → `docs/process/AUTHOR.md` → `docs/STATUS.md`;
|
||||
`AGENTS.md` → `docs/process/AUTHOR.md`, then the task packet
|
||||
(`node scripts/task-packet.mjs --issue NN`); the status snapshot
|
||||
(docs/STATUS.md) only when resuming a session or preparing a release;
|
||||
- reviewer (spec or code): `docs/SCOPE.md` → `AGENTS.md` →
|
||||
`docs/process/REVIEWER.md`, then the issue body and its comments;
|
||||
- changing the pipeline, the gates or the process itself: `docs/SCOPE.md` →
|
||||
@@ -100,8 +102,9 @@ batched comment with a proposed default for each question and `blocked` on top o
|
||||
## Commits and branches
|
||||
|
||||
Hooks install themselves on `npm ci` (`prepare` → `scripts/install-hooks.mjs`);
|
||||
`git config core.hooksPath` must print `.githooks`. Every non-merge commit
|
||||
carries **terminal** trailers:
|
||||
`git config core.hooksPath` must print `.githooks`. Every non-merge commit that
|
||||
touches anything outside class C (docs) carries **terminal** trailers; a
|
||||
docs-only commit needs none (`PROCESS.md` §3 п.10, #701):
|
||||
|
||||
```text
|
||||
Issue: #123
|
||||
|
||||
+5
-4
@@ -94,12 +94,11 @@ issue, but they do not replace it or maintain a separate checklist.
|
||||
|
||||
```bash
|
||||
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
|
||||
npm ci # frontend toolchain
|
||||
npm ci # frontend toolchain; `prepare` installs .githooks
|
||||
npm run typecheck # tsc --noEmit (strict)
|
||||
npm test # node:test — pure logic, i18n parity, tap-action security
|
||||
npm run build # tsc + rollup → dist/houseplan-card.js
|
||||
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
|
||||
npm install # also installs .githooks through the prepare script
|
||||
pip install -r tests_backend/requirements.txt && python -m pytest tests_backend -q # CI pins, HA harness included
|
||||
```
|
||||
|
||||
### Why `--filter=blob:none` (#345)
|
||||
@@ -127,7 +126,9 @@ Linux CI or WSL (`bash scripts/wsl-setup.sh --verify`). Without an importable
|
||||
## Ground rules
|
||||
|
||||
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
|
||||
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
|
||||
`docs/ARCHITECTURE.md` for changes to the data model, WS API or coordinate
|
||||
system; `docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new
|
||||
gotchas. A docs-only commit needs no trailers (`PROCESS.md` §3 п.10).
|
||||
- Every UI string goes through `src/i18n/<lang>.json`; follow the
|
||||
[Translations](#translations) flow for registry and backend parity.
|
||||
- The committed bundle changes only in a release candidate: `npm run
|
||||
|
||||
+105
-33
@@ -7,8 +7,10 @@
|
||||
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
|
||||
> входят в общий флоу сразу на `S7-code-review`.
|
||||
>
|
||||
> **Область действия:** обязателен для владельца и для любого агента. Читается
|
||||
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
|
||||
> **Область действия:** обязателен для владельца и для любого агента. Целиком
|
||||
> его читает тот, кто правит конвейер, гейты или сам процесс, — сразу после
|
||||
> `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`; автор и ревьюер входят через
|
||||
> ролевые конспекты ниже и открывают раздел канона по ссылке (#634, #701). Живёт в
|
||||
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
|
||||
> его не содержал вовсе.
|
||||
>
|
||||
@@ -24,8 +26,9 @@
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
|
||||
> процесса в репозитории; `AGENTS.md` — его короткое обязательное резюме, а
|
||||
> внешний `CODEX-RUNBOOK.md` — только маршрутизатор к канону и историческим
|
||||
> инструкциям. Текущие версии, состояние конкретных issue, runtime pins и списки
|
||||
> внешние `CODEX-RUNBOOK.md` и `CLAUDE.md` папки владельца — только маршрутизаторы
|
||||
> к канону и историческим инструкциям: правил в них нет, и в замер цены входа
|
||||
> (`scripts/entry-cost.mjs`) они не входят — CI их не видит (#701). Текущие версии, состояние конкретных issue, runtime pins и списки
|
||||
> jobs не копируются в производную прозу: они читаются из своих исполняемых
|
||||
> источников.
|
||||
> При расхождении этого документа с `.github/workflows/*.yml` и `scripts/*`
|
||||
@@ -201,8 +204,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
|
||||
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
|
||||
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
|
||||
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
|
||||
`Issue: #<NN>` и `User-Visible: yes|no`.
|
||||
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт трейлеры
|
||||
`Issue: #<NN>` и `User-Visible: yes|no`; коммит только из документации — без
|
||||
трейлеров (§3 п.10, #701).
|
||||
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
|
||||
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
|
||||
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
|
||||
@@ -251,7 +255,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
|
||||
то, чего они не проверяли (#430).
|
||||
|
||||
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
|
||||
Мутант в реестре `scripts/mutation-registry.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
@@ -261,8 +265,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
DOM/CSS paint, измеренной геометрии, trusted pointer/lifecycle или browser
|
||||
wall-time, а id — попасть в размеченный реестр
|
||||
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
|
||||
показывает число browser guards против лимита 200, краснеет при росте выше
|
||||
лимита и предупреждает о любом id без browser-обоснования; ревьюер проверяет
|
||||
показывает число browser guards против ориентира 200 и предупреждает — не
|
||||
краснеет (#699) — при росте выше него и о любом id без browser-обоснования;
|
||||
удалять чужой мутант ради числа не нужно; ревьюер проверяет
|
||||
не только наличие строки, но и невозможность более дешёвого `node --test`.
|
||||
|
||||
Считаются **защитные AC без названного свидетеля**, а не мутанты на
|
||||
@@ -299,9 +304,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
(`scripts/monolith-metrics.mjs`: делегаты, члены порта, `host.`, приватные
|
||||
члены порта и харнесса, байты `dist/`), база — `scripts/monolith-baseline.json`;
|
||||
гейт `npm run lint:unused` (в `gate:small` и Validate после сборки) красит
|
||||
рост любого из них и любой мёртвый код по `noUnusedLocals` вне порта и
|
||||
харнесса. Снижение фиксируется тем же коммитом
|
||||
(`node scripts/unused-locals-gate.mjs --update`); рост — только с записью в
|
||||
рост любого из них сверх полосы (#699: делегаты, члены и приватные порта и
|
||||
харнесса — 5, `host.` — 25, байты `dist/` — 2 000) и любой мёртвый код по
|
||||
`noUnusedLocals` вне порта и харнесса. Снижение ветку не красит — базу до
|
||||
факта опускает бета (§8, «Храповики»); рост сверх полосы — только с записью в
|
||||
issue задачи и правкой базы в том же коммите.
|
||||
- **Смок входит в сценарий через публичную поверхность (#629).** DOM с
|
||||
контрактными хуками, события HA и фикстуры, тестовый фасад `window.__hpTest`
|
||||
@@ -385,16 +391,26 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
fast-forward (`--commit-if-stale`, коммит класса C) — и публикацией документа ревью ТЗ
|
||||
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
|
||||
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
|
||||
конфликтуют на нём по построению. Руками не правится. Конфликт ребейза, в котором **все** пути —
|
||||
конфликтуют на нём по построению. Сам `INDEX.md` руками не правится никогда. Конфликт ребейза, в котором **все** пути —
|
||||
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
|
||||
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
|
||||
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Шаг Validate
|
||||
вместе с любым другим путём — прежний отказ с перечнем файлов. Ещё два общих
|
||||
файла не конфликтуют по смыслу (#698): записи ченджлогов в `## Unreleased`
|
||||
объединяет встроенный драйвер `merge=union` (`.gitattributes`), а на конфликте в
|
||||
`scripts/monolith-baseline.json` ребейз берёт сторону `dev` — числа
|
||||
объединённого дерева судит гейт связности монолита (`npm run lint:unused`) на
|
||||
Validate кандидата. Сегодня пять чисел исходника он судит точно, байты `dist/` —
|
||||
с полосой 2 000 Б; поэтому сторона `dev` в базе краснеет, если задача сама
|
||||
меняла эти числа, — это прежний возврат автору, только после Validate, а не до
|
||||
ревью. Полоса у всех шести чисел — #699. Ни ченджлоги, ни база не входят в
|
||||
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе. Шаг Validate
|
||||
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
|
||||
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
|
||||
Правка `docs/reviews/`
|
||||
руками — перенос в `legacy/`, удаление — сопровождается
|
||||
`node scripts/reviews-index.mjs` в том же коммите. Прежде чем брать
|
||||
Ручная правка каталога
|
||||
`docs/reviews/` — перенос документов в `legacy/`, удаление — сопровождается
|
||||
пересборкой индекса `node scripts/reviews-index.mjs` в том же коммите: индекс
|
||||
меняет генератор, а не рука. Прежде чем брать
|
||||
задачу по подсистеме, стоит прочитать её строки в индексе: что находили и чем
|
||||
закрывали — там, а не в тысяче файлов. Уроки, пережившие свою задачу,
|
||||
собираются в `docs/LESSONS.md` с датой и ссылкой на источник.
|
||||
@@ -439,8 +455,10 @@ dev, ни при публикации документа код-ревью: ин
|
||||
|
||||
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
|
||||
разработке» или дальше.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
|
||||
пронумерованных AC с указанием доказательства и назначенного исполнителя.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ в объёме трека
|
||||
(§5: на `track:ask` — с зелёным ревью ТЗ; на `show` — до трёх AC; на `ship` —
|
||||
строка «что меняется и чем проверить»), доказательства для каждого AC и
|
||||
назначенного исполнителя. Инфраструктурная задача ТЗ не пишет (§1).
|
||||
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
|
||||
комментарием с именем ветки. У одного исполнителя одновременно не более одного
|
||||
issue в разработке.
|
||||
@@ -460,14 +478,17 @@ dev, ни при публикации документа код-ревью: ин
|
||||
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
|
||||
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
|
||||
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
|
||||
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
|
||||
том же коммите. Документационный коммит — только файлы класса C — трейлеров
|
||||
не требует (#701, как `skip issue` у CPython); хук `commit-msg`, CI и
|
||||
`process-gate` судят одинаково. Коммит класса D несёт `Release:` (п.12). После `cherry-pick -x` служебная строка `(cherry picked
|
||||
from ...)` должна оставаться выше финального блока трейлеров: перед push
|
||||
проверяем порядок через `git show -s --format=full HEAD`.
|
||||
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
|
||||
коммитом документация не бывает.
|
||||
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
|
||||
принятие эталонов с доказательством ревью: URL Linux CI run либо хеш
|
||||
аттестованного WSL-артефакта.
|
||||
принятие эталонов с доказательством ревью: трейлер `Release:` и ровно один
|
||||
источник — URL Linux CI run (`Baseline-Reviewed:`) либо хеш аттестованного
|
||||
WSL-артефакта (`Baseline-Reviewed-Local:`), §10.1.
|
||||
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
|
||||
полному Linux-артефакту: либо GitHub CI, либо `npm run golden:wsl:capture` в
|
||||
WSL/ext4 с clean опубликованным SHA и машинно-проверяемым паспортом. Второй
|
||||
@@ -507,8 +528,9 @@ dev, ни при публикации документа код-ревью: ин
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
чем циклов, поэтому порог проверки №7 в `scripts/process-gate.mjs`
|
||||
(`REVIEW_DOC_LIMIT` — шесть документов одного вида на issue) выше лимита
|
||||
циклов: четыре цикла плюс два ребейза.
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
@@ -619,7 +641,7 @@ dev, ни при публикации документа код-ревью: ин
|
||||
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
|
||||
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
|
||||
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код; принимать golden — кроме сдвига своей задачи с меткой `ci:golden` по §3 п.13 (§8, #697) |
|
||||
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
|
||||
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
|
||||
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
|
||||
@@ -668,6 +690,11 @@ issue #NN
|
||||
которое не может ответить на эти два вопроса, описывает работу, а не изменение
|
||||
продукта.
|
||||
|
||||
**Дефект растра, резкости или композитинга** — на любом треке (#690): до кода
|
||||
нужен свидетель, красный на старом коде именно по симптому владельца, а в AC —
|
||||
подтверждение владельца в реальном GPU-браузере. Headless-доказательства мало:
|
||||
#685 доказал правку в headless Linux и не устранил симптом, его откатил #689.
|
||||
|
||||
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
|
||||
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
|
||||
|
||||
@@ -724,9 +751,16 @@ issue #NN
|
||||
## 8. Гейты
|
||||
|
||||
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
|
||||
покрывающий изменённые поверхности (действующее правило владельца):
|
||||
покрывающий изменённые поверхности (действующее правило владельца).
|
||||
Обязательную часть исполняет одна команда, `npm run gate:small`
|
||||
(`scripts/gate-small.mjs` — единственный источник её состава, #701): сборка с
|
||||
typecheck, юниты, целостность и бюджет бандла, `no-new-any`, запрет синхронного
|
||||
чтения layout в render, запрет записи смоков в приватное состояние,
|
||||
`lint:unused` и вывод `smoke-select`. Команды ниже — то же самое по отдельности
|
||||
плюс то, что по диффу и AC:
|
||||
|
||||
```
|
||||
npm run gate:small # обязательная часть одной командой
|
||||
npx tsc --noEmit
|
||||
npm test
|
||||
npm run build && node scripts/bundle-policy.mjs --verify HEAD
|
||||
@@ -762,7 +796,10 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
|
||||
диффу, печатает
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
прикладывается к ревью вместе с решением по каждой строке; если связь не
|
||||
доказана, он печатает визуальный минимум — восемь смоков режимов, слоёв и
|
||||
отрисовки, локально меньше минуты, — и автор гоняет его до `S7`, `gate:small --
|
||||
--smokes` делает это сам, #690), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml`
|
||||
@@ -770,7 +807,8 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
(`workflow_dispatch`, только артефакт) с приёмкой вручную: `npm run docs:accept --
|
||||
--reviewed --from=<распакованный артефакт>` (#246). Ручной путь остаётся
|
||||
релиз-менеджеру, если бот недоступен. Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
|
||||
кадре, и набор из «не того» браузера переписывает все кадры (их число — в
|
||||
`demo/docs/screenshots.mjs`) без единого
|
||||
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота
|
||||
проверяет релиз-менеджер, коммит ручной приёмки делает человек.
|
||||
@@ -809,6 +847,22 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
|
||||
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
|
||||
|
||||
**Храповики — полоса над потолком беты** (#699, решение владельца 2026-09-28).
|
||||
Строки двух ядер (`test/core-file-budget.test.mjs`), gzip стартового и ленивых
|
||||
графов (`scripts/bundle-budget.mjs`) и числа связности монолита
|
||||
(`scripts/monolith-baseline.json`) судятся одним правилом: потолок — факт
|
||||
последней беты; ветка задачи краснеет, только если вышла выше потолка больше
|
||||
чем на полосу (ядро — 50 строк, графы и `dist/` — 2 000 Б, числа монолита — по
|
||||
`METRIC_BANDS`), а снижение её не красит. Прежде храповики были двусторонними с
|
||||
нулевым запасом: параллельные задачи конфликтовали на общих числах, пересчитывали
|
||||
их после ребейза и упирались в потолок, потому что перед ними влили чужую (#689
|
||||
после #691). Вторая сторона храповика живёт на бете: релиз-менеджер на
|
||||
кандидате выполняет `node scripts/ratchets.mjs tighten` — потолки опускаются
|
||||
(и поднимаются на принятый линией рост) до факта одним коммитом вместе с
|
||||
кандидатом; `npm run release:prerelease` печатает рыхлые потолки
|
||||
предупреждением. Абсолютный бюджет стартового графа
|
||||
(`INITIAL_VIEW_GZIP_BUDGET`) остаётся стеной.
|
||||
|
||||
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
|
||||
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
@@ -929,7 +983,8 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
|
||||
|
||||
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
|
||||
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
|
||||
`Issue: #NN`, требует ровно один `User-Visible: yes|no` (коммит только из
|
||||
файлов класса C — без трейлеров, §3 п.10, #701), а для коммитов,
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
|
||||
`Baseline-Reviewed: <URL GitHub run>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
|
||||
@@ -982,7 +1037,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`;
|
||||
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
|
||||
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
|
||||
7. документов ревью одного вида (`SPEC-REVIEW`, `CODE-REVIEW`) на один issue
|
||||
не больше шести (`-r1`…`-r6`, `REVIEW_DOC_LIMIT`): четыре цикла плюс два
|
||||
ребейза (§4).
|
||||
|
||||
С токеном GitHub:
|
||||
|
||||
@@ -1003,6 +1060,11 @@ Validate стартует от этого push и успевает прочит
|
||||
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
|
||||
`S7-code-review`, то есть заведомо вне рабочего множества.
|
||||
|
||||
**Инфраструктурный диапазон статуса не требует** (#562, §1): если в диапазоне нет
|
||||
ни одного файла класса A, задача ещё не вошла в поток — она войдёт в него сразу на
|
||||
`S7-code-review`, — и отсутствие S-метки не отказ. Признак механический, по
|
||||
диффу, а не по метке `infra`.
|
||||
|
||||
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
|
||||
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
|
||||
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
|
||||
@@ -1089,7 +1151,12 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
|
||||
триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в
|
||||
`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными —
|
||||
сверяются ровно эти шесть файлов, список держит
|
||||
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
|
||||
`test/default-branch-workflows.test.mjs`. Расхождение красит push в `dev` и
|
||||
заводит одно issue владельцу (`[workflow-sync]`), а на ветке задачи —
|
||||
предупреждение в сводке (#700): к её изменению оно отношения не имеет, и чинит
|
||||
его тот, кто зеркалит в `main`. Так же судятся внешние ссылки документации
|
||||
(`check-docs --external=warn` на ветках `issue/*`): упавший чужой сайт не
|
||||
возвращает задачу. `performance.yml` в список не входит:
|
||||
по расписанию он судит `main` собственным телом из `main`.
|
||||
|
||||
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
|
||||
@@ -1230,7 +1297,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск
|
||||
к другому диффу не применим, §2.10; метка снимается и ставится заново, чтобы
|
||||
событие запустило новый заход, #706), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и
|
||||
@@ -1238,7 +1306,11 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
|
||||
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
|
||||
прилетевший после слияния, её сохраняет, и комментарий об этом говорит.
|
||||
Влитая ветка никому не нужна, а агент, ищущий ветку по номеру, иначе может
|
||||
взять устаревшую.
|
||||
|
||||
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
|
||||
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
|
||||
|
||||
@@ -340,6 +340,9 @@ const out = await page.evaluate(async () => {
|
||||
await hp.setTool('select');
|
||||
await selectStair(straight.id);
|
||||
result.frameReturnsUnderSelect = !!frame();
|
||||
// #693: the plan editor keeps `move` over the stair body.
|
||||
result.editorStairBodyCursorIsMove = getComputedStyle(
|
||||
stairNode(straight.id)?.querySelector('.hp-stair-hit') ?? document.body).cursor === 'move';
|
||||
await hp.setTool('stairs');
|
||||
|
||||
// Saving the properties dialog untouched changes nothing (#676 AC5):
|
||||
@@ -426,6 +429,10 @@ const out = await page.evaluate(async () => {
|
||||
result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link'
|
||||
&& linkedNode?.getAttribute('data-target-state') === 'active'
|
||||
&& getComputedStyle(linkedNode).cursor === 'pointer';
|
||||
// #693: in View the hit area on top of the outline shows the link's
|
||||
// pointer, not the editor's `move`.
|
||||
const hitCursor = (node) => getComputedStyle(node?.querySelector('.hp-stair-hit') ?? document.body).cursor;
|
||||
result.viewLinkHitCursorIsPointer = hitCursor(linkedNode) === 'pointer';
|
||||
|
||||
// Hover on a link announces the target floor; every other target state
|
||||
// stays silent (#676 AC8). The mouse pointer type enables hover.
|
||||
@@ -451,6 +458,7 @@ const out = await page.evaluate(async () => {
|
||||
};
|
||||
result.missingTargetHoverIsSilent = await hoverTip(await withTarget(null), null)
|
||||
&& stairNode(linked.id)?.getAttribute('data-target-state') === 'missing';
|
||||
result.viewStairWithoutTargetHasNoMoveOrPointer = !['move', 'pointer'].includes(hitCursor(stairNode(linked.id)));
|
||||
result.selfTargetHoverIsSilent = await hoverTip(await withTarget('f1'), null)
|
||||
&& stairNode(linked.id)?.getAttribute('data-target-state') === 'self';
|
||||
result.deletedTargetHoverIsSilent = await hoverTip(await withTarget('no-such-space'), null)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- In View, the mouse cursor over a stair link is now a pointer instead of the
|
||||
move cursor; stairs without a valid target keep the plan's ordinary cursor
|
||||
([#693](https://github.com/Matysh/houseplan-card/issues/693)).
|
||||
|
||||
## v1.78.0 — 2026-09-28
|
||||
|
||||
- Volumetric 2.5D View is now a public General settings option. Device markers,
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- В режиме View над лестницей-ссылкой теперь курсор-указатель вместо курсора
|
||||
перемещения; у лестниц без действующей цели — обычный курсор плана
|
||||
([#693](https://github.com/Matysh/houseplan-card/issues/693)).
|
||||
|
||||
## v1.78.0 — 2026-09-28
|
||||
|
||||
- Объёмный 2.5D-вид стал публичной опцией в общих настройках. Положение
|
||||
|
||||
+8
-2
@@ -330,7 +330,10 @@ the `dev` branch. Access to the owner's instances is not documented here.
|
||||
|
||||
- The card module URL contains `?v=<VERSION from const.py>`. Browsers keep the ES module in
|
||||
memory cache: after deploying new JS **bump VERSION in const.py and restart HA**,
|
||||
otherwise a plain F5 will keep the old version.
|
||||
otherwise a plain F5 will keep the old version. This is a deployment step — a
|
||||
release candidate or your own local stand. An ordinary task commit bumps neither
|
||||
`VERSION` nor the committed bundle: both change only in a commit with a
|
||||
`Release:` trailer (#657, `PROCESS.md` §1).
|
||||
- After a page reload the HA frontend (with kiosk-mode) sometimes leaves the view empty
|
||||
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
|
||||
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
|
||||
@@ -525,7 +528,10 @@ above); the dev stand takes the head of `dev` from the `dev-build` branch
|
||||
Prepare the candidate as usual: synchronize every version field, add dated RU
|
||||
and EN changelog sections, update the production bundle snapshots with
|
||||
`npm run bundle:release` (since #657 the only commit that may change them; it
|
||||
carries the `Release:` trailer) and write the
|
||||
carries the `Release:` trailer), then lower the ratchets to the candidate's
|
||||
facts with `node scripts/ratchets.mjs tighten` (#699: it rewrites the core line
|
||||
caps, the gzip graph ceilings and `scripts/monolith-baseline.json` from the
|
||||
fresh `dist/`; commit them with the candidate) and write the
|
||||
short bilingual body in `docs/RELEASE-NOTES.md`. That file is the one current
|
||||
instance of the canonical `## Основное` / `## Highlights` template; its two
|
||||
changelog links must be pinned to the new tag.
|
||||
|
||||
+4
-3
@@ -3,8 +3,9 @@
|
||||
Датированные выводы, пережившие свою задачу (#635). Каждый — одной мыслью, со
|
||||
ссылкой на источник: документ ревью, issue, аудит. Полный след решений — в
|
||||
`docs/reviews/INDEX.md` и комментариях issue; здесь только то, что меняет
|
||||
поведение на следующей задаче. Конспекты ролей (#634) и промпт ревьюера
|
||||
ссылаются сюда.
|
||||
поведение на следующей задаче. Читается перед задачей в затронутой подсистеме
|
||||
вместе с её строками в `docs/reviews/INDEX.md` (PROCESS.md, «Индекс документов
|
||||
ревью»); конспекты ролей сюда не ведут — урок, ставший правилом, живёт в каноне.
|
||||
|
||||
| Дата | Урок | Источник |
|
||||
|---|---|---|
|
||||
@@ -12,7 +13,7 @@
|
||||
| 2026-09-06 | Число смоков/тестов не живёт в тексте: считается командой (`npm run inventory`). | #472, PROCESS.md |
|
||||
| 2026-09-09 | Мутанты по диффу — на кандидате, не на каждом push: 48 из 56 часов job-минут за два дня ушли в отменённые прогоны. | #510 |
|
||||
| 2026-09-13 | Ненулевой exit гарда ≠ «мутант пойман»: setup-failure, invalid-mutation и survived различаются; засчитывается только assertion-killed. | #558, #568 |
|
||||
| 2026-09-18 | Ревью на не принятых эталонах — потерянный цикл: `golden:verify` ревьюер гоняет лично, эталоны принимаются до `S7`. | CODE-REVIEW-598-r1 H1 |
|
||||
| 2026-09-18 | Ревью на не принятых эталонах — потерянный цикл: `golden:verify` ревьюер гоняет лично, эталоны принимаются до `S7`. С #697 — только у задачи с `ci:golden`; сдвиг остальных принимает бот на `dev` перед бетой. | CODE-REVIEW-598-r1 H1, #697 |
|
||||
| 2026-09-20 | Стенд ≠ Home Assistant: нативный `<dialog>` и `ha-dialog` — разные ветки; свидетель для реального HA — фикстура #505, а не golden стенда. | аудит 22.09 A/H1, #607, #609 |
|
||||
| 2026-09-20 | Контролируемое поле без `live()` показывает не то число, что в состоянии; клампить на коммите (`change`), не на каждом символе. | аудит 22.09 A/H2, #608 |
|
||||
| 2026-09-21 | Лог без итоговой строки — обрыв, а не «ok»: отчёт судит по маркеру завершения и исходу шага, не по отсутствию `FAIL`. | #604 |
|
||||
|
||||
+4
-1
@@ -53,7 +53,10 @@ activation a no-op.
|
||||
|
||||
In View, hovering a stair with a mouse shows the card tooltip "Go to floor
|
||||
<title>" when — and only when — the stair is a valid link (`active` target
|
||||
state); a missing, self, deleted or fixed-floor target shows no tooltip.
|
||||
state); a missing, self, deleted or fixed-floor target shows no tooltip. The
|
||||
mouse cursor follows the same condition: a pointer over a valid link, the
|
||||
plan's ordinary cursor over any other stair. The move cursor is the Plan
|
||||
editor's (#693).
|
||||
|
||||
In an ordinary multi-space card, a clean click/tap or keyboard activation on a
|
||||
valid stair switches to the target tab and restores that floor's remembered
|
||||
|
||||
+4
-2
@@ -3,8 +3,10 @@
|
||||
> The current state for a resuming session: a generated snapshot, the current
|
||||
> cycle and the standing decisions that explain it. Rules are not here — the
|
||||
> process is `PROCESS.md`, release mechanics are `docs/DEVELOPMENT.md` › Release,
|
||||
> and task scope and status live in GitHub Issues and their labels. Update a row
|
||||
> in the same commit as the change it describes (`PROCESS.md` §2.6).
|
||||
> and task scope and status live in GitHub Issues and their labels. The snapshot
|
||||
> below is generated — never edit it by hand; the prose sections after it are
|
||||
> edited by hand, a row in the same commit as the change it describes
|
||||
> (`PROCESS.md` §2.6).
|
||||
|
||||
## Snapshot
|
||||
|
||||
|
||||
+30
-15
@@ -28,10 +28,12 @@ golden, а то, чего автоматика не видит, собрано
|
||||
подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при
|
||||
котором луч не достигает единственного окна (#89), — та же ошибка в
|
||||
геометрии.
|
||||
4. **Тест, охраняющий механизм, сопровождается мутантом** в
|
||||
`scripts/mutation-registry.mjs`: 2–5 строк патча, воспроизводящего поломку,
|
||||
против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям
|
||||
с обычными юнитами мутант не нужен.
|
||||
4. **Мутант в `scripts/mutation-registry.mjs` обязателен, когда защита живёт в
|
||||
продуктовом коде и проверяется дорогим гейтом** (смок, бэкенд, golden):
|
||||
2–5 строк патча, воспроизводящего поломку, против которой тест заведён, и
|
||||
тест обязан на ней краснеть. Для чистых юнитов достаточно прогона со снятой
|
||||
защитой, приведённого в документе ревью. Правило одно — PROCESS.md §2.7
|
||||
(#701); на треке `show` отсутствие мутанта — Low (§10.4).
|
||||
5. **Тавтологический ассерт — читающий то же свойство, которое код только что
|
||||
выставил, — не пишется вовсе.** Он может упасть только при удалении строки,
|
||||
но не при её неработоспособности.
|
||||
@@ -45,7 +47,8 @@ golden, а то, чего автоматика не видит, собрано
|
||||
|
||||
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
|
||||
полный прогон — workflow `mutation-gate.yml` (шесть чересполосных шардов
|
||||
`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут на 810 мутантах, #604;
|
||||
`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут, #604; число мутантов
|
||||
считает `npm run inventory`, а не этот текст;
|
||||
один зафиксированный commit/tree для всего прогона; каждый артефакт несёт
|
||||
identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает
|
||||
смешанные, неполные или прерванные по таймауту evidence даже при частичном
|
||||
@@ -250,6 +253,12 @@ c._drag = { id, sx, sy }; // private-ok: #NNN состояние жеста, о
|
||||
|
||||
## Локальный набор перед пушем (#343)
|
||||
|
||||
Обязательный локальный набор — `npm run gate:small`; его состав живёт в
|
||||
`scripts/gate-small.mjs` и нигде больше не переписывается (#701). Хук `pre-push`
|
||||
гоняет его сам для веток задач — таблица «В хуке» ниже. Ручной
|
||||
`node scripts/pre-push-gate.mjs` из этого раздела — расширенный прогон:
|
||||
типы, юниты, смоки и мутанты по диффу.
|
||||
|
||||
Красный CI — дорогой способ узнать о проблеме: пять минут ожидания, а при
|
||||
код-ревью ещё и лишний раунд. Прецедент назван в задаче: находка r2-H1 в #329
|
||||
стоила целого раунда и ловилась локальным `npm test`.
|
||||
@@ -296,7 +305,10 @@ node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1
|
||||
Три вида ответа `smoke-select` различаются и здесь: дифф без исполняемого кода —
|
||||
«смоки не требуются»; прямое совпадение или зарегистрированная связь —
|
||||
прогоняется; **связь не доказана** — отдельная громкая строка, потому что это не
|
||||
«проверять нечего»: молчание стоило #234 бета-блокирующего регресса.
|
||||
«проверять нечего»: молчание стоило #234 бета-блокирующего регресса. Вместе с ней
|
||||
печатается визуальный минимум (`VISUAL_MINIMUM` в `scripts/smoke-select.mjs`):
|
||||
восемь смоков режимов, слоёв и отрисовки, которые `--smokes` гоняет вместе с
|
||||
выборкой (#690). CSS-правка #687 без него не выбрала `smoke_modes`.
|
||||
|
||||
### В хуке — по умолчанию для веток задач (#633)
|
||||
|
||||
@@ -439,14 +451,17 @@ golden отображаемая версия идёт через seam `displayVe
|
||||
- [ ] `node --test test/core-file-budget.test.mjs` — потолки на
|
||||
`src/houseplan-card.ts` и `src/houseplan-editor-runtime.ts`.
|
||||
|
||||
Гейт-храповик: наверх не пускает, вниз требует зафиксировать выигрыш. Если он
|
||||
покраснел, вариантов два, и оба нормальные — вынести из ядра столько же строк,
|
||||
сколько добавили, либо поднять потолок отдельным решением, объяснив его в
|
||||
ревью. Молча поднять не выйдет: число живёт в тесте и попадает в дифф.
|
||||
Гейт-храповик с полосой (#699): потолок — факт последней беты, ветка может
|
||||
вырасти над ним на 50 строк. Если он покраснел, вариантов два, и оба
|
||||
нормальные — вынести из ядра столько же строк, сколько добавили сверх полосы,
|
||||
либо поднять потолок отдельным решением, объяснив его в ревью. Молча поднять не
|
||||
выйдет: число живёт в тесте и попадает в дифф.
|
||||
|
||||
Вторая половина правила (уменьшение ниже потолка на 250 строк тоже краснеет)
|
||||
нужна затем, что без неё вынос двух тысяч строк ничего не меняет: потолок
|
||||
остаётся прежним, и ядро дорастает до него обратно «в рамках бюджета».
|
||||
Вторая половина правила — выигрыш фиксируется — переехала на бету: без неё
|
||||
вынос двух тысяч строк ничего не меняет, и ядро дорастает до потолка обратно
|
||||
«в рамках бюджета». Поэтому на кандидате беты `node scripts/ratchets.mjs
|
||||
tighten` опускает потолки ядер, графов бандла и базу монолита до факта
|
||||
(PROCESS.md §8, «Храповики»); ветку задачи уменьшение не красит.
|
||||
|
||||
## Локальный CI-совместимый toolchain (#557)
|
||||
|
||||
@@ -736,8 +751,8 @@ See `demo/performance/README.md` for commands and the budget-review contract.
|
||||
Остальные пункты с пометкой `[manual]` описывали обычное поведение режимов,
|
||||
редакторов, диалогов, правил значков и `houseplan-space-card`. Для них нужен
|
||||
смок, а не ручная проверка: задача, которая трогает такую поверхность без
|
||||
смока, пишет его в том же коммите (правило `[auto: …]` выше). Пункты
|
||||
`[manual]` разделов ниже остаются как есть.
|
||||
смока, пишет его в том же коммите (правило `[auto: …]` выше). Оставшиеся пункты
|
||||
`[manual]` разделов выше остаются как есть.
|
||||
|
||||
## Release regression quickies
|
||||
|
||||
|
||||
+16
-10
@@ -78,6 +78,10 @@
|
||||
входит в issue. Всё, чего пользователь не наблюдает, автор решает сам и
|
||||
записывает блоком «принято предположительно, поменять свободно». Смешанный
|
||||
вопрос делится ([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- Дефект растра, резкости или композитинга: до кода нужен свидетель, красный
|
||||
на старом коде именно по симптому владельца; в AC — подтверждение владельца
|
||||
в реальном GPU-браузере. Headless-доказательства мало (урок #685 → #689,
|
||||
[§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа ·
|
||||
вариант по умолчанию. Пока ждём ответа, issue остаётся в `S3-spec` и
|
||||
получает `blocked` ([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
@@ -94,12 +98,14 @@
|
||||
## Реализация (`S6-in-progress`)
|
||||
|
||||
- Занятие: `Взял: <роль> · сессия <id> · ветка issue/NN-slug`; WIP — одна
|
||||
задача в разработке на исполнителя
|
||||
задача в разработке на исполнителя, не больше трёх на цикл релиза и двух в
|
||||
код-ревью
|
||||
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
|
||||
[§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры `Issue: #<NN>` и
|
||||
`User-Visible: yes|no`. `User-Visible: yes` требует правок в обоих changelog
|
||||
в том же коммите. После `cherry-pick -x` трейлеры остаются последним блоком
|
||||
- Ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт
|
||||
трейлеры `Issue: #<NN>` и `User-Visible: yes|no`, коммит только из
|
||||
документации — без трейлеров. `User-Visible: yes` требует правок в обоих
|
||||
changelog в том же коммите. После `cherry-pick -x` трейлеры остаются последним блоком
|
||||
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
|
||||
[§3 п.10](../../PROCESS.md#3-правила)).
|
||||
- Автотесты — часть реализации: каждый AC с пометкой `unit` / `backend` /
|
||||
@@ -137,13 +143,13 @@
|
||||
|
||||
## Гейты перед хендоффом
|
||||
|
||||
- Минимальный набор по изменённым поверхностям: `npx tsc --noEmit`,
|
||||
`npm test`, `npm run build` + `bundle-policy --verify`, `smoke-select` и
|
||||
целевые смоки, `no-new-any`; по диффу — `model-invariants`,
|
||||
- Обязательная часть — `npm run gate:small`: его состав живёт в
|
||||
`scripts/gate-small.mjs` и нигде не переписывается. По диффу и AC сверх
|
||||
него — целевые смоки из вывода `smoke-select` (связь не доказана — его
|
||||
визуальный минимум, `--smokes` гоняет его сам, #690), `model-invariants`,
|
||||
`pytest tests_backend`, junction parity; `golden:verify` — только с меткой
|
||||
`ci:golden`. Команды —
|
||||
в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает
|
||||
обязательную часть (`docs/TESTING.md`, «Локальный набор перед пушем»).
|
||||
`ci:golden` ([§8](../../PROCESS.md#8-гейты); `docs/TESTING.md`, «Локальный
|
||||
набор перед пушем»).
|
||||
- Бандл в коммит задачи не идёт: сборка переписывает отслеживаемый `dist/`,
|
||||
перед коммитом — `npm run bundle:clean`; хук `commit-msg` отклоняет пути
|
||||
бандла без трейлера `Release:` (#657, [§1](../../PROCESS.md#1-основное-правило)).
|
||||
|
||||
@@ -83,9 +83,10 @@
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
- Условие честности сужения: ревьюер обязан перечислить, какие гейты прогнал,
|
||||
какие нет и почему ([§8](../../PROCESS.md#8-гейты)).
|
||||
- Зелёный `pytest tests_backend` без Home Assistant скипает `test_ha_*.py` и
|
||||
ничего не доказывает — это «чего не проверял» (`docs/TESTING.md`;
|
||||
[§8](../../PROCESS.md#8-гейты)).
|
||||
- Зелёный `pytest tests_backend` без Home Assistant `test_ha_*.py` не
|
||||
собирает вовсе — их нет ни в `passed`, ни в `skipped` (строка `HA harness NOT
|
||||
collected`), и такой прогон про HA ничего не доказывает — это «чего не
|
||||
проверял» (`docs/TESTING.md`; [§8](../../PROCESS.md#8-гейты)).
|
||||
|
||||
## Повторный раунд
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# CODE-REVIEW-690-r1
|
||||
|
||||
**Issue:** #690 · **Этап:** code (`S7-code-review`) · **Трек:** show (инфраструктурный) ·
|
||||
**Заход:** r1 · **Блокирующих циклов:** 0 из 2 · **Материал:** ветка
|
||||
`issue/690-visual-minimum`, ровно `52a56430abbf98f66233b9637a5f7c78cc8d0600`
|
||||
(рабочая копия на нём; `git rev-parse HEAD` сверен).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Три AC из ТЗ (тело issue, п. 1′/4/попутно, решения владельца 2026-09-28):
|
||||
|
||||
- **AC1.** `smoke-select` при исполняемом диффе без доказанной связи печатает и
|
||||
отдаёт в JSON фиксированный «визуальный минимум» (5–8 смоков режимов, слоёв,
|
||||
отрисовки, среди них `smoke_modes`); `gate:small -- --smokes` гоняет его
|
||||
вместе с выборкой; при доказанной связи или без исполняемого диффа минимума
|
||||
нет.
|
||||
- **AC2.** Правило для дефектов растра/резкости/композитинга — свидетель,
|
||||
красный на старом коде по симптому владельца, подтверждение в реальном
|
||||
GPU-браузере — в PROCESS §7.1 и `docs/process/AUTHOR.md`.
|
||||
- **AC3.** `task-packet.mjs` описывает прежнюю метку `trivial` как продуктовый
|
||||
поток, читаемый как `track:show` (§5.1), а не как «короткий трек без ТЗ».
|
||||
|
||||
Диффа класса A нет (только `PROCESS.md`, `docs/TESTING.md`,
|
||||
`docs/process/AUTHOR.md`, `scripts/gate-small.mjs`,
|
||||
`scripts/mutation-registry.mjs`, `scripts/smoke-select.mjs`,
|
||||
`scripts/task-packet.mjs`, `test/smoke-select.test.mjs`,
|
||||
`test/task-packet.test.mjs`) — класс B+C, `User-Visible: no` в единственном
|
||||
коммите корректен (видимого пользователю поведения карточки нет). Трейлер
|
||||
`Issue: #690` на месте.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты подтверждены зелёным Validate на этом SHA (run 36528250333,
|
||||
`typecheck`+`npm test`+`npm run build`+`bundle-policy --verify`) — не
|
||||
перегонялись повторно.
|
||||
|
||||
Дифф по `smoke-select` на самом материале:
|
||||
|
||||
```
|
||||
$ node scripts/smoke-select.mjs --base origin/dev --head HEAD
|
||||
Исполняемого frontend-диффа нет (src/**/*.ts не тронут).
|
||||
Browser-smoke этим диффом не выбираются...
|
||||
```
|
||||
|
||||
`noExecutableDiff: true` — ни один из названных в АС1 смоков, ни визуальный
|
||||
минимум задачей не требуется: сама задача не трогает `src/**`. Браузерные
|
||||
смоки не гонялись — по выводу инструмента они не выбраны, а тело issue не
|
||||
называет смок отдельно для этого прогона.
|
||||
|
||||
По каждому AC — что проверено и как:
|
||||
|
||||
| AC | Чем доказан | Чем краснеет | Прогон |
|
||||
|---|---|---|---|
|
||||
| AC1 | `test/smoke-select.test.mjs` (`#690 п.1′: …`, 2 новых теста) + 3 именованных мутанта | `visual-minimum-silent-again` (обнуляет `visualMinimum`), `visual-minimum-on-proven-link` (минимум течёт по одному факту исполняемости, не по недоказанности), `gate-small-skips-visual-minimum` (убирает минимум из `smokesToRun`) | Прогнал сам: baseline зелёный (`node --test --test-name-pattern="#690" test/smoke-select.test.mjs` — 2/2 pass); применил патч каждого из трёх мутантов вручную — все три ломают ровно ожидаемый тест (проверено построчно, дифф отменён после каждой пробы). `node scripts/mutation-registry-check.mjs` — 0 (реестр непротиворечив, `find`-строки совпадают с текущим кодом) |
|
||||
| AC2 | Текст в PROCESS §7.1 (строки 693–696) и `docs/process/AUTHOR.md` (раздел ТЗ) | защитный AC текстового/процессного рода — «чем краснеет» не применимо к формулировке правила самой по себе; доказательство — точное соответствие формулировке владельца из комментария 2026-09-28 | Проверено чтением, не исполнением: сверил текст правила в обоих файлах с формулировкой владельца дословно — совпадает; место в §7.1 логически стоит рядом с «Размытое место не додумывается» (тот же смысловой блок про доказательства до кода), не конфликтует с соседним текстом |
|
||||
| AC3 | `test/task-packet.test.mjs` (обновлённое `productFlowEvidence` ожидание), `scripts/mutation-registry.mjs` (`find`/`replace` синхронизированы с новой строкой) | тот же мутант `task-packet-trivial-is-product-flow`, `because` тоже обновлён, чтобы не противоречить #695 | `node --test test/task-packet.test.mjs` — 16/16 pass; сверил формулировку с уже принятым #695 (`scripts/process-track.mjs:39`, `PROCESS.md:935`) — согласовано, не изобретает нового понятия |
|
||||
|
||||
Дополнительно проверено:
|
||||
- `node --test test/smoke-select.test.mjs` — 9/9 pass (полный файл, не только `#690`-подмножество).
|
||||
- `node --test test/gate-small.test.mjs` — 5/5 pass.
|
||||
- `git diff --stat` — бандл (`dist/**`) не тронут, `Release:`-трейлер не нужен.
|
||||
- Единственное число, дублирующееся в диффе, — «восемь» смоков визуального
|
||||
минимума: один источник (`VISUAL_MINIMUM.length` в `scripts/smoke-select.mjs`),
|
||||
PROCESS.md и `docs/TESTING.md` его лишь цитируют текстом, не хардкодят
|
||||
отдельно; `test/smoke-select.test.mjs` проверяет диапазон 5–8, а не точное
|
||||
число, так что смещение массива само по себе не расходится с документами
|
||||
сильнее допустимого. Все 8 файлов `demo/smoke_*.mjs` из `VISUAL_MINIMUM`
|
||||
существуют (проверено `existsSync`, тест это же утверждает).
|
||||
|
||||
## Находки
|
||||
|
||||
Нет. Изменение узкое, три AC разобраны по коду и тестам, мутанты
|
||||
воспроизведены вручную и действительно красные на инвертированном
|
||||
поведении, документный AC2 текстуально совпадает с решением владельца, AC3
|
||||
согласован с уже принятым #695 и не вводит расхождений.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- `VISUAL_MINIMUM` — 8 существующих смоков, включает `smoke_modes.mjs`
|
||||
(именно его не хватило #687 по тексту issue); список заморожен
|
||||
(`Object.freeze`).
|
||||
- `selectSmokes()`: `visualMinimum` заполняется только при `unproven`, при
|
||||
доказанной связи (прямой или зарегистрированной) и при `noExecutableDiff`
|
||||
остаётся пустым — подтверждено фикстурами `unproven.diff`,
|
||||
`234-chain-thickness.diff`, `docs-only.diff`.
|
||||
- `smokesToRun()` в `gate-small.mjs` включает `visualMinimum` в список для
|
||||
прогона наравне с `direct`/`registered`, с дедупликацией через `Set`.
|
||||
- CLI (`report()`) печатает визуальный минимум отдельной громкой строкой
|
||||
внутри ветки `НЕОПРЕДЕЛЁННОСТЬ», не подменяя её.
|
||||
- `gate-small.mjs` итоговый лог называет причину («связь не доказана — …
|
||||
визуальный минимум (#690)») отдельно от обычного случая.
|
||||
- Мутант `task-packet-trivial-is-product-flow` обновлён синхронно с
|
||||
изменением текста в `scripts/task-packet.mjs` — `find`-строка совпадает,
|
||||
`mutation-registry-check.mjs` подтверждает.
|
||||
- Единственный коммит, трейлеры `Issue:`/`User-Visible:` корректны,
|
||||
ветка от актуального `dev` (`6e073134`), файлов класса A нет.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный `npm run gate:small`, `npm test`, `npm run build`,
|
||||
`golden:verify`, `pytest tests_backend`, `npm run invariants` — не
|
||||
прогонял: Validate зелёный на этом SHA закрывает дешёвые гейты
|
||||
(typecheck/test/build/bundle-verify), диф не трогает Python и геометрию,
|
||||
`ci:golden` не выставлена.
|
||||
- Браузерные смоки (включая сам `VISUAL_MINIMUM`) — не гонял: диф не
|
||||
трогает `src/**`, `smoke-select.mjs --base origin/dev --head HEAD` на
|
||||
материале прямо отвечает `noExecutableDiff`, обязательного смока в АС нет.
|
||||
- Мутанты по диффу как отдельный обязательный гейт ревью — не запрашивались
|
||||
(трек `show`, #690/REVIEWER.md «Трек show»); три названных в АС1 мутанта
|
||||
всё же проверены вручную вручную, потому что AC1 защитный и без третьей
|
||||
колонки таблицы «чем краснеет» находка была бы Medium.
|
||||
- Performance-профили — не названы в AC, не гонял.
|
||||
- Поведение конвейера `_process.yml`/меток `trivial` на реальных открытых
|
||||
issue — не проверял вживую; ограничился текстовым сравнением с уже
|
||||
принятым #695.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. AC1–AC3 выполнены и доказаны каждый своим способом (тест+мутант,
|
||||
чтение текста, тест); находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/690-visual-minimum`, коммит `52a56430abbf` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `3e5463d83784c360485d89dd297cbd4cbb816e5c`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 3e5463d83784
|
||||
```
|
||||
- Тело issue: `4796e50b141f022b6ef98e61809bd29d3dd30e0dcf87f368da80b697565b156b`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,222 @@
|
||||
# CODE-REVIEW #698 r1
|
||||
|
||||
Материал: `origin/dev..HEAD`, ровно один коммит `ea2c000af5e4bd47140c2c255fca16ad2a136f6c`
|
||||
поверх `dev@9e4bfb43` (рабочая копия уже на нём). Диапазон: `.gitattributes`,
|
||||
`PROCESS.md`, `scripts/merge-candidate.mjs`, `scripts/mutation-registry.mjs`,
|
||||
`scripts/rebase-generated.mjs`, `test/rebase-generated.test.mjs`. Файлов класса
|
||||
A нет — инфраструктурный трек (§1), вход сразу на `S7-code-review`, метка
|
||||
`track:show` на issue.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Issue #698 называет три источника из 14/48 возвратов #600–#691 по общим
|
||||
файлам, где правки двух задач не противоречат друг другу: (1) записи
|
||||
`## Unreleased` в обоих CHANGELOG, (2) `docs/images/screenshots.json`, (3)
|
||||
`scripts/bundle-budget.mjs` / `scripts/monolith-baseline.json`. Формального ТЗ
|
||||
нет (инфраструктура, PROCESS.md §1) — проверял по тексту раздела «Предложение»
|
||||
issue и по факту автоматизации, а не по чужому изложению.
|
||||
|
||||
Реализовано:
|
||||
|
||||
- `.gitattributes`: `merge=union` для `docs/CHANGELOG.md` и
|
||||
`docs/CHANGELOG.ru.md` — строки `## Unreleased` объединяются на ребейзе,
|
||||
слиянии и `git merge-tree` без остановки.
|
||||
- `scripts/rebase-generated.mjs`: `UPSTREAM_WINS = ['scripts/monolith-baseline.json']`
|
||||
— конфликт именно на этом пути решается в пользу `dev` (`checkout --ours`),
|
||||
без abort; любой другой путь по-прежнему аборт с полным списком.
|
||||
- `scripts/merge-candidate.mjs`: `PATCH_ID_EXCLUDES` расширяет прежнее
|
||||
исключение `docs/reviews` на оба CHANGELOG и `monolith-baseline.json` — эти
|
||||
файлы больше не входят в patch-id кандидата, соседняя строка не отправляет
|
||||
зелёную задачу обратно на ревью.
|
||||
- `docs/images/screenshots.json` сознательно не тронут: автор в заявочном
|
||||
комментарии ссылается на #697 («после #697 ветки задач его не коммитят»).
|
||||
На момент этого ревью #697 в статусе `S1-new` (не начат, не триажирован) —
|
||||
конфликты на этом файле продолжат случаться до его реализации. Автор это
|
||||
прямо раскрыл, не спрятал; тема не входит в делаемый диапазон #698 (см.
|
||||
«Что проверено»).
|
||||
- `scripts/bundle-budget.mjs` не включён ни в `UPSTREAM_WINS`, ни в
|
||||
`PATCH_ID_EXCLUDES`, хотя исходная «Проблема» issue называет его рядом с
|
||||
`monolith-baseline.json`. Прочитал файл: он вперемешку хранит числовые
|
||||
потолки и содержательные словари (`GRAPH_LABELS`,
|
||||
`NAMESPACE_ENGLISH_CONSUMERS`, `SUPPORT_LAZY_MARKERS`), большинство числовых
|
||||
потолков уже имеют полосу (`INITIAL_VIEW_CEILING_BAND`,
|
||||
`LAZY_GRAPH_CEILING_BAND`) — это не тот же тип конфликта, что чистый
|
||||
производный JSON `monolith-baseline.json`, и слепое разрешение «взять dev»
|
||||
рисковало бы стереть чужой словарь. Решение не включать файл в это ревью не
|
||||
оспариваю.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Validate на материале | CI run на `ea2c000a` (ссылка в задаче ревью) | зелёный; дешёвые гейты (`tsc`, `npm test`, `build`+`bundle-policy --verify`) не перегонял — приняты по этой ссылке (#343) |
|
||||
| Целевой юнит-набор | `node --test test/rebase-generated.test.mjs` | 15/15 pass |
|
||||
| Целевой юнит-набор | `node --test test/merge-candidate.test.mjs` | 19/19 pass |
|
||||
| Целевой юнит-набор | `node --test test/rebase-on-dev.test.mjs` | 6/6 pass |
|
||||
| Согласованность конспекта | `node --test test/process-digests.test.mjs` | 5/5 pass |
|
||||
| `process-gate.mjs` (офлайн) | `node scripts/process-gate.mjs --issues` | «гейт пройден, предупреждений 1» — предупреждение информационное: инфраструктурный диапазон, статусная метка не обязательна до `S7-code-review` |
|
||||
| Реестр мутантов, дешёвая половина | `node --test --test-name-pattern="every mutant patch anchors exactly once\|every guard command points" test/mutation-gate.test.mjs` | 2/2 — якоря трёх новых мутантов не отстали от кода |
|
||||
| Мутант `changelog-union-driver-dropped` | вручную убрал строку `docs/CHANGELOG.md merge=union` из `.gitattributes`, прогнал `node --test --test-name-pattern="#698: записи ченджлога" test/rebase-generated.test.mjs`, восстановил файл | **краснеет** (`AssertionError`, `false !== true`) |
|
||||
| Мутант `monolith-baseline-conflict-is-manual-again` | вручную заменил `UPSTREAM_WINS` на `Object.freeze([])` в `scripts/rebase-generated.mjs`, прогнал `--test-name-pattern="#698: конфликт в базе"`, восстановил файл | **краснеет** (`ok:false, reason:'manual'` вместо ожидаемого авторазрешения) |
|
||||
| Мутант `merge-patch-id-sees-changelog` | вручную вернул `PATCH_ID_EXCLUDES` к списку без обоих CHANGELOG в `scripts/merge-candidate.mjs`, прогнал `--test-name-pattern="#698: patch-id"`, восстановил файл | **краснеет** (`AssertionError` на `docs/CHANGELOG.md`) |
|
||||
| Чтение живого гейта монолита | `scripts/monolith-metrics.mjs: compareWithBaseline` | `band = name === 'bundleBytes' ? bundleBand : 0` — для 5 из 6 метрик допуск нулевой сегодня (см. находку Medium) |
|
||||
| Провенанс коммита | `git show -s --format=full ea2c000a` | `Issue: #698`, `User-Visible: no` — корректно, ни одно пользовательское поведение продукта не задето |
|
||||
| Восстановление рабочей копии | `git status --porcelain` после каждой ручной мутации | пусто — дерево вернулось к исходному состоянию перед следующим шагом |
|
||||
|
||||
Не прогонял (и почему): `npx tsc --noEmit`, полный `npm test`, `npm run build`
|
||||
+ сверка трёх копий бандла, `node scripts/check-docs.mjs`, `mutation-gate --check`
|
||||
целиком (с пересборкой бандла на мутанта) — приняты по зелёному Validate на
|
||||
этом SHA (#343); диапазон не трогает `src/**`, `check-docs.mjs` не требуется.
|
||||
Golden/скриншоты, браузерные смоки, `pytest tests_backend`, инварианты модели,
|
||||
performance-профили — не применимо: диапазон не трогает визуал, Python или
|
||||
геометрию плана.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **`--ours`/`--theirs` во время rebase не перепутаны.** Комментарий в коде
|
||||
(«На ребейзе `--ours` — сторона, на которую ребейзят, то есть dev») верен
|
||||
git-семантике (во время `git rebase` HEAD — это цель, `--theirs` —
|
||||
переигрываемый коммит); тест «конфликт в базе метрик монолита решается в
|
||||
пользу dev» подтверждает это исполнением реального git: `hostRefs` после
|
||||
разрешения равен значению из коммита `neighbour` (dev), а не `task`.
|
||||
- **`planStop`/`rebaseRegenerating` не путают `UPSTREAM_WINS` с `extra`/`index`.**
|
||||
И `manual`, и возвращаемый `extra` корректно исключают оба новых пути;
|
||||
`plan.upstream` обрабатывается отдельной веткой (`checkout --ours` + `add`)
|
||||
до `plan.index`. Тест «база метрик вместе с конфликтом в коде» подтверждает,
|
||||
что при добавлении обычного (не покрытого) конфликтующего пути срабатывает
|
||||
прежний полный abort со списком `['a.mjs', 'scripts/monolith-baseline.json']`,
|
||||
а не частичное разрешение.
|
||||
- **Оба вызывающих получают правило бесплатно.** `UPSTREAM_WINS` зашита в
|
||||
`scripts/rebase-generated.mjs`, а не передаётся через `opts.extra` —
|
||||
и авторский `rebase-on-dev.mjs`, и конвейерный
|
||||
`merge-candidate.mjs::rebaseOnto` используют общий `rebaseRegenerating` без
|
||||
дублирования логики.
|
||||
- **Все три новых мутационных гвардиана реально ловят регресс** — проверено
|
||||
ручным воспроизведением (патч → красный тест → откат), а не по имени `ok` в
|
||||
реестре мутантов.
|
||||
- **Safety net не теряется при неверном бейзлайне.** После авторазрешения в
|
||||
пользу dev реальное расхождение вычисленных метрик всё равно ловит Validate
|
||||
самого кандидата: `merge-candidate.mjs` диспатчит `validate.yml` на
|
||||
смерженный SHA и ждёт результат, красный возвращает задачу в
|
||||
`S6-in-progress` — молча неверный бейзлайн в `dev` не проходит.
|
||||
- **Патч-id корректно исключает то, что ребейз сливает сам** — соседняя
|
||||
правка нейтрального файла не отправляет зелёную задачу обратно на ревью;
|
||||
подтверждено мутационным тестом.
|
||||
- **Трейлеры и класс изменений корректны**: `Issue: #698`, `User-Visible: no`,
|
||||
ни одного файла класса A; `PROCESS.md` документирует изменение тем же
|
||||
коммитом.
|
||||
- **`scripts/bundle-budget.mjs` обоснованно вне скоупа этой правки** (см.
|
||||
«Скоуп») — не переоткрываю как находку.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе — блокирует зелёный вердикт)
|
||||
|
||||
**PROCESS.md утверждает существование «теста полосы на Validate» для
|
||||
монолитного бейзлайна, которого сегодня нет.**
|
||||
|
||||
Новый абзац этого же коммита (`PROCESS.md`, рядом с #643): «...а на конфликте
|
||||
в `scripts/monolith-baseline.json` ребейз берёт сторону `dev` — число для
|
||||
объединённого дерева судит тест полосы на Validate (#699)». Формулировка в
|
||||
настоящем времени читается как факт о текущей автоматизации.
|
||||
|
||||
По факту: `scripts/monolith-metrics.mjs:compareWithBaseline` сегодня считает
|
||||
`band = name === 'bundleBytes' ? bundleBand : 0` — для пяти из шести чисел
|
||||
(`delegates`, `portMembers`, `hostRefs`, `portPrivates`, `harnessPrivates`)
|
||||
допуск нулевой, точное совпадение, снижение тоже FAIL. Это буквально проблема,
|
||||
которую описывает сам #699 («Двусторонние храповики сейчас с нулевым или
|
||||
минимальным запасом» / «5 чисел монолита... точное совпадение»), а #699
|
||||
находится в статусе `S1-new` — не триажирован, тем более не смёржен. «Тест
|
||||
полосы» реально существует только для одной метрики из шести (`bundleBytes`,
|
||||
`BUNDLE_BYTES_BAND`) — и этот механизм существовал до #698/#699, не является
|
||||
их результатом.
|
||||
|
||||
Канон сам формулирует правило для себя (шапка PROCESS.md): «При расхождении
|
||||
этого документа с... `scripts/*` побеждает фактическая автоматизация.
|
||||
Расхождение при этом не игнорируется, а заводится issue с меткой `process`».
|
||||
Данный абзац — ровно такое расхождение, внесённое тем же коммитом, который
|
||||
должен был его избежать: автор или ревьюер, читающий канон после мержа #698,
|
||||
но до принятия #699, вправе решить, что небольшой дрейф `hostRefs`/`delegates`
|
||||
после авторазрешения конфликта в пользу dev будет прощён «полосой» — а на деле
|
||||
Validate уронит кандидата точным несовпадением, и время уйдёт на выяснение,
|
||||
почему написанное в каноне не совпадает с поведением автоматизации.
|
||||
|
||||
Не High: реальное расхождение по-прежнему красит Validate кандидата (см. «Что
|
||||
проверено» — safety net не теряется), неверный бейзлайн не проходит в `dev`
|
||||
молча. Но это Medium и в скоупе задачи — формулировку добавила именно она,
|
||||
чинится точечной правкой в этом же issue, например: «текущий Validate
|
||||
сравнивает число с `dev` точно (нулевой допуск для всех метрик, кроме
|
||||
`bundleBytes`) — станет полосой после #699; до этого несовпадение красит
|
||||
Validate кандидата, а не проходит тихо».
|
||||
|
||||
### Low (сняты ревьюером, без правки)
|
||||
|
||||
- `scripts/rebase-on-dev.mjs`: предиктивный `--dry-run`-лог
|
||||
(`splitConflicts`/`predicted.manual`) не знает ни про `merge=union` в
|
||||
`.gitattributes`, ни про `UPSTREAM_WINS` — при параллельной правке
|
||||
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` или `scripts/monolith-baseline.json`
|
||||
он печатает «менялись с обеих сторон... возможен ручной конфликт», хотя
|
||||
фактический ребейз (несколько строк ниже, через `rebaseRegenerating`)
|
||||
разрешает его автоматически. Чисто предсказательный текст для человека,
|
||||
сам исход ребейза не меняет и уже проверен тестами выше. Снимаю: cosmetic,
|
||||
не мешает ни одному AC этой задачи; дешевле поправить при следующей правке
|
||||
файла, отдельный issue для строки лога избыточен.
|
||||
- Риск «`merge=union` склеит содержательный конфликт двух правок одной старой
|
||||
строки ченджлога в два дубля» — раскрыт самим автором в хендофф-комментарии
|
||||
как принятый компромисс для append-only лога (дубль увидит релиз-менеджер
|
||||
при сборке нот беты). Согласен с оценкой, не переоткрываю.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Живой прогон `merge-candidate.mjs::rebaseOnto` / `_process.yml` на реальном
|
||||
GitHub Actions с настоящим конфликтом между двумя параллельными PR — не
|
||||
воспроизводил. Логика проверена реальным git (временные репозитории в
|
||||
тестах, не мок) и точечными ручными мутациями — это не то же самое, что
|
||||
живой запуск конвейера на паре реальных веток. **Записываю явно: для факта
|
||||
исполнения на реальном GitHub Actions — проверено чтением и контролируемым
|
||||
тестом на настоящем git, не живым запуском workflow.**
|
||||
- Полный `npm test` (весь набор) не перегонял целиком — принят по зелёному
|
||||
Validate на этом SHA (#343); точечно перегонял только файлы из дельты
|
||||
(`rebase-generated`, `merge-candidate`, `rebase-on-dev`, `process-digests`)
|
||||
плюс дешёвую половину `mutation-gate.test.mjs`.
|
||||
- `npx tsc --noEmit`, `npm run build` + сверка трёх копий бандла, полный
|
||||
`mutation-gate --check` (с пересборкой на мутанта) — не перегонял, приняты
|
||||
по Validate; диапазон не трогает `src/**`.
|
||||
- golden/скриншоты, браузерные смоки, `pytest tests_backend`, инварианты
|
||||
модели, performance — не применимо: диапазон не трогает визуал, Python или
|
||||
геометрию плана.
|
||||
- Долгосрочная устойчивость find/replace-якорей трёх новых мутантов к
|
||||
дальнейшим ребейзам `dev` — проверена только на текущем материале.
|
||||
- Существо #697 и #699 — вне скоупа этой задачи, не проверял по существу,
|
||||
только прочитал их текущий статус (`S1-new` у обоих) для находки Medium
|
||||
выше.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная блокирующая находка — Medium, в скоупе: формулировка PROCESS.md,
|
||||
добавленная этим же коммитом, описывает как факт «тест полосы на Validate»
|
||||
для монолитного бейзлайна, которого сегодня нет (нулевой допуск для пяти из
|
||||
шести чисел, полоса существует только для `bundleBytes` и не как результат
|
||||
этой задачи). Сам механизм разрешения конфликтов — `.gitattributes` union,
|
||||
`UPSTREAM_WINS`, `PATCH_ID_EXCLUDES` — реализован корректно, проверен
|
||||
исполнением настоящего git и ручными мутациями по всем трём защищаемым
|
||||
линиям, и не создаёт риска молчаливого слияния неверного числа (реальное
|
||||
расхождение по-прежнему красит Validate кандидата). Без High это жёлтый
|
||||
вердикт: формулировка канона должна отражать факт автоматизации, а не
|
||||
опережать ещё не принятую задачу (#699).
|
||||
|
||||
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/2 · High: 0 · Medium: 1 → в задаче
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/698-rebase-auto-resolve`, коммит `ea2c000af5e4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `cb640cf165f861e9b1f8827a5dc40df3c5435e77`
|
||||
```
|
||||
git log --all --format='%H %T' | grep cb640cf165f8
|
||||
```
|
||||
- Тело issue: `e17d18976553c2da4042b649a2073c581aa5108f58a57cf7c2902b6d639859c0`
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,144 @@
|
||||
# CODE-REVIEW #698 r2
|
||||
|
||||
Материал: `origin/dev..HEAD`, два коммита `ea2c000a` (r1, уже разобран) и
|
||||
`97d819ad` (новый, только он в скоупе этого раунда) поверх `dev@9e4bfb43`
|
||||
(рабочая копия уже на `97d819ad`). Дельта раунда — `git diff ea2c000a..97d819ad`:
|
||||
`PROCESS.md` (10 строк), `scripts/rebase-generated.mjs` (комментарий,
|
||||
5 строк), плюс публикация `docs/reviews/CODE-REVIEW-698-r1.md` (артефакт
|
||||
конвейера, не правка автора). Файлов класса A по-прежнему нет —
|
||||
инфраструктурный трек (§1), `track:show`.
|
||||
|
||||
Раунд не первый (PROCESS.md §2.10, #214): разбор — по дельте r1→r2, а не
|
||||
заново. Дельта локальна и мала (переформулировка одного абзаца канона и
|
||||
парного комментария в коде, без изменения логики) — полный повторный разбор
|
||||
механизма (`.gitattributes` union, `UPSTREAM_WINS`, `PATCH_ID_EXCLUDES`) не
|
||||
требуется, он не менялся байт в байт (см. «Унаследовано из r1»).
|
||||
|
||||
## Скоуп
|
||||
|
||||
r1 закрыл ровно одну блокирующую находку — Medium: абзац `PROCESS.md`,
|
||||
добавленный коммитом #698, утверждал в настоящем времени существование
|
||||
«теста полосы на Validate» для всех шести чисел монолита, которого на
|
||||
момент r1 не было (полоса — только у `bundleBytes`, остальные пять чисел
|
||||
судятся точно; полоса для всех шести — отдельная нетриаженная задача #699).
|
||||
|
||||
Коммит `97d819ad` переписывает этот абзац и парный комментарий к
|
||||
`UPSTREAM_WINS`, называя текущий допуск так, как он реализован сегодня, а
|
||||
не так, как он будет выглядеть после гипотетического #699. Код не менялся
|
||||
(автор говорит об этом прямо в хендоффе, и `git diff ea2c000a..97d819ad --
|
||||
scripts/` подтверждает: правка только в блоке `/** ... */` над
|
||||
`UPSTREAM_WINS`, объявление и вся исполняемая логика — байт в байт те же).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Validate на материале (`97d819ad`) | CI run, ссылка в задаче ревью | success — дешёвые гейты (`tsc`, полный `npm test`, `build`+сверка бандла) не перегонял, приняты по этой ссылке (#343) |
|
||||
| Соответствие формулировки коду: гейт | `grep -n "lint:unused" package.json` | `"lint:unused": "node scripts/unused-locals-gate.mjs"` — имя гейта в новом абзаце названо верно |
|
||||
| Соответствие формулировки коду: допуск | `scripts/monolith-metrics.mjs:262-274` (`BUNDLE_BYTES_BAND = 2_000`, `compareWithBaseline`: `band = name === 'bundleBytes' ? bundleBand : 0`) | подтверждает буквально: пять чисел — точное совпадение, `bundleBytes` — полоса 2000 Б, ровно как теперь написано в `PROCESS.md` |
|
||||
| Соответствие формулировки коду: где стоит гейт | `grep -n "lint:unused" .github/workflows/validate.yml` → строка 809 | гейт действительно часть Validate кандидата, а не только локальный скрипт |
|
||||
| Целевой юнит-набор (дельта r1, не менялась в r2, но перепроверил на новом SHA) | `node --test test/rebase-generated.test.mjs` | 15/15 pass |
|
||||
| Согласованность конспектов ролей | `node --test test/process-digests.test.mjs` | 5/5 pass — переписанный абзац не входит в дословные «ключевые правила» `AUTHOR.md`/`REVIEWER.md`, там о #698/#699 ничего не сказано (`grep` по обоим файлам не нашёл упоминаний «тест полосы»/`monolith-baseline`/#699) — расхождения конспекта с новым каноном нет |
|
||||
| Реестр мутантов, дешёвая половина | `node --test --test-name-pattern="every mutant patch anchors exactly once\|every guard command points" test/mutation-gate.test.mjs` | 2/2 — три мутанта из r1 по-прежнему анкерятся, комментарий не входит в их `find`/`replace` |
|
||||
| Провенанс коммита r2 | `git show -s --format=full 97d819ad` | `Issue: #698`, `User-Visible: no` — корректно, правка только канона и комментария, продуктового поведения нет |
|
||||
| `process-gate.mjs` на дельте r2 | `node scripts/process-gate.mjs --range 97d819ad~1..97d819ad` | «гейт пройден, предупреждений 0» |
|
||||
|
||||
Не прогонял (и почему): `npx tsc --noEmit`, полный `npm test`, `npm run build`
|
||||
+ сверка трёх копий бандла, `node scripts/check-docs.mjs`, `mutation-gate
|
||||
--check` целиком — приняты по зелёному Validate на `97d819ad` (#343);
|
||||
дельта r2 не трогает `src/**` и не меняет исполняемый код. Golden/скриншоты,
|
||||
браузерные смоки, `pytest tests_backend`, инварианты модели,
|
||||
performance-профили — не применимо, как и в r1: диапазон не трогает визуал,
|
||||
Python или геометрию плана.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Медиан-находка r1 закрыта по существу, а не косметически.** Новая
|
||||
формулировка называет реальный гейт (`npm run lint:unused` →
|
||||
`scripts/unused-locals-gate.mjs` → `compareWithBaseline`), реальный
|
||||
сегодняшний допуск (пять чисел — точно, `bundleBytes` — полоса 2000 Б) и
|
||||
корректно относит полосу для всех шести чисел к будущему #699, не выдавая
|
||||
её за факт. Проверено не по тексту хендоффа, а прямым чтением
|
||||
`scripts/monolith-metrics.mjs` и `.github/workflows/validate.yml` — цитаты
|
||||
выше воспроизводимы построчно.
|
||||
- **Прежний вывод об отсутствии тихого прохождения неверного бейзлайна не
|
||||
меняется.** Новый текст сам это формулирует явно: «сторона `dev` в базе
|
||||
краснеет, если задача сама меняла эти числа — это прежний возврат
|
||||
автору, только после Validate, а не до ревью» — совпадает с тем, что уже
|
||||
было проверено исполнением в r1 (safety net через Validate кандидата).
|
||||
- **Код механизма не тронут.** `git diff ea2c000a..97d819ad --
|
||||
scripts/rebase-generated.mjs` — правка ограничена телом doc-комментария
|
||||
над `UPSTREAM_WINS`; объявление константы, `planStop`,
|
||||
`rebaseRegenerating` — те же байты, что проверялись мутациями в r1.
|
||||
- **Трейлеры и класс изменений корректны**: `Issue: #698`, `User-Visible: no`.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка (r1) | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium: `PROCESS.md` утверждал как факт «тест полосы на Validate» для всех шести чисел монолита, которого не существует (полоса только у `bundleBytes`) | Абзац переписан в настоящем времени под фактическую автоматизацию: назван гейт (`lint:unused`), сегодняшний нулевой допуск для пяти чисел и полоса 2000 Б для `bundleBytes`; полоса всех шести явно отнесена к будущему #699 | `PROCESS.md` (диапазон строк 392-401, коммит `97d819ad`); тот же текст продублирован в комментарии к `UPSTREAM_WINS` в `scripts/rebase-generated.mjs`; подтверждено чтением `scripts/monolith-metrics.mjs:262-274` и `.github/workflows/validate.yml:809` — формулировка и код совпадают дословно по числам и названию гейта |
|
||||
|
||||
## Унаследовано из r1 (без повторной проверки)
|
||||
|
||||
- Механизм разрешения конфликтов — `.gitattributes` (`merge=union` на обоих
|
||||
CHANGELOG), `UPSTREAM_WINS` в `scripts/rebase-generated.mjs`,
|
||||
`PATCH_ID_EXCLUDES` в `scripts/merge-candidate.mjs`, три новых мутационных
|
||||
гвардианта — реализован корректно и проверен исполнением реального git и
|
||||
ручными мутациями. Документ: `docs/reviews/CODE-REVIEW-698-r1.md`, SHA
|
||||
материала `ea2c000af5e4bd47140c2c255fca16ad2a136f6c`. Не перепроверял
|
||||
заново мутациями в r2: код этих файлов в дельте r1→r2 не менялся (см.
|
||||
«Скоуп» и «Как проверялось» выше — единственная правка `scripts/` этого
|
||||
раунда — комментарий).
|
||||
- Обоснование, почему `docs/images/screenshots.json` и
|
||||
`scripts/bundle-budget.mjs` вне скоупа #698, — из r1, не пересматривал:
|
||||
первое отложено на #697 (раскрыто автором), второе — смешанный файл с
|
||||
содержательными словарями, не тот же тип конфликта, что чистый
|
||||
производный JSON.
|
||||
- Два Low из r1 (предиктивный `--dry-run`-лог `rebase-on-dev.mjs` не знает
|
||||
про новые правила; риск дублирования строки ченджлога при настоящем
|
||||
конфликте двух правок) остаются снятыми ревьюером без правки — дельта r2
|
||||
их не касается.
|
||||
|
||||
## Находки
|
||||
|
||||
Новых находок в дельте r2 нет. High: 0, Medium: 0.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный `npm test`, `npx tsc --noEmit`, `npm run build` + сверка бандла —
|
||||
приняты по зелёному Validate на `97d819ad` (#343); дельта r2 не трогает
|
||||
`src/**`.
|
||||
- Существо #699 (проектирование полосы для всех шести чисел) — вне скоупа
|
||||
#698, не проверял по существу; только убедился, что новая формулировка
|
||||
канона не выдаёт его результат за уже реализованный.
|
||||
- Живой прогон конвейера на реальном GitHub Actions с настоящим конфликтом
|
||||
между двумя параллельными PR — как и в r1, не воспроизводил; логика
|
||||
проверена в r1 реальным git во временных репозиториях, дельта r2 её не
|
||||
меняет.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная блокирующая находка r1 (Medium: формулировка канона опережала
|
||||
не принятую задачу #699) закрыта точечной правкой того же абзаца — новый
|
||||
текст сверен построчно с фактическим кодом гейта и подтверждён
|
||||
исполняемыми числами (`compareWithBaseline`, `BUNDLE_BYTES_BAND`,
|
||||
`validate.yml:809`). Код механизма не менялся, ранее проверенные мутациями
|
||||
гарантии в силе. Новых находок нет.
|
||||
|
||||
Вердикт: зелёный · заход r2 · блокирующих циклов 1/2 · High: 0 · Medium: 0
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/698-rebase-auto-resolve`, коммит `97d819ad6757` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `0e024d33642f2428aefb2486c6e12395e3e05209`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 0e024d33642f
|
||||
```
|
||||
- Тело issue: `e17d18976553c2da4042b649a2073c581aa5108f58a57cf7c2902b6d639859c0`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,213 @@
|
||||
# CODE-REVIEW-699-r1
|
||||
|
||||
Issue: #699 · этап: code · трек: show · заход: r1 · блокирующих циклов до этого раунда: 0/2
|
||||
|
||||
Материал раунда: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`,
|
||||
вершина ветки `issue/699-ratchet-bands` = `709c5b8a38f4ac16f7ce18593ef0a40d672742cb`.
|
||||
Validate на этом SHA — success (run 36486854256). Ребейз на dev не делался
|
||||
(трек show, #696): dev впереди на 14 коммитов, слияние без конфликта
|
||||
(`git merge-tree`, подтверждено автором); материал — ветка как есть.
|
||||
|
||||
Предыдущего раунда ревью по этой задаче не было: заход `3dd032d7` (первая
|
||||
попытка) упал на Validate до того, как ревью запускалось («ревью не
|
||||
запускалось», код никто не читал, цикл не потрачен). Второй заход `709c5b8a`
|
||||
чинит ровно то, что назвал прогон, и это первый заход, дошедший до ревьюера —
|
||||
поэтому разбор ниже полный, а не по дельте (§2.10 к этой задаче неприменим).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Инфраструктурная задача (процесс/гейты), файлов класса A нет. Меняет правило
|
||||
двусторонних храповиков с нулевым запасом на полосу над потолком последней
|
||||
беты: строки двух ядер (`test/core-file-budget.test.mjs`, полоса 50), gzip
|
||||
initial View и трёх ленивых графов (`scripts/bundle-budget.mjs`, полоса
|
||||
2000 Б), шесть чисел связности монолита (`scripts/monolith-metrics.mjs`,
|
||||
`scripts/unused-locals-gate.mjs`, `METRIC_BANDS`), и переводит лимит 200
|
||||
браузерных мутантов из жёсткого в ориентир (`mutation-browser-policy.mjs`,
|
||||
`mutation-registry-check.mjs`). Второй коммит правит найденный Validate дефект:
|
||||
CLI `bundle-budget.mjs` проверял абсолютный бюджет раньше потолка беты, из-за
|
||||
чего рост сверх полосы (`ceiling + band` выше `INITIAL_VIEW_GZIP_BUDGET`)
|
||||
маскировался бюджетом и не был наблюдаем. Новый файл `scripts/ratchets.mjs` —
|
||||
инструмент релиз-менеджера: `report [--warn]` (факт против потолков,
|
||||
используется в `release:prerelease`) и `tighten` (потолки := факт, ручной шаг
|
||||
на кандидате беты).
|
||||
|
||||
Владелец принял оба открытых вопроса ТЗ (issue-комментарий 2026-09-28): полоса
|
||||
ядра = 50 строк, лимит браузерных мутантов становится ориентиром. Оба решения
|
||||
корректно реализованы теми же числами, что названы в issue.
|
||||
|
||||
User-Visible: no на обоих коммитах, верно — `src/**` не тронут, продуктовое
|
||||
поведение не меняется. Трейлеры `Issue: #699` и `User-Visible: no` на месте на
|
||||
обоих коммитах.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты подтверждены зелёным Validate на этом SHA (`typecheck`,
|
||||
`npm test`, `npm run build` + bundle-policy) — не перегонялись повторно.
|
||||
Поверх этого, по диффу:
|
||||
|
||||
| Гейт | Прогнан | Результат |
|
||||
|---|---|---|
|
||||
| `node --test test/process-digests.test.mjs` | да | 5/5 ok — конспект `docs/process/REVIEWER.md` не разошёлся с PROCESS.md (диф этой задачи REVIEWER.md не трогает) |
|
||||
| `node --test test/ratchets.test.mjs test/core-file-budget.test.mjs test/monolith-metrics.test.mjs test/mutation-gate.test.mjs test/bundle-assets.test.mjs` | да | 125/125 ok |
|
||||
| `node scripts/ratchets.mjs report` (на закоммиченном `dist/`) | да | числа совпали один в один с таблицей из комментария автора: оба ядра и пять чисел монолита `равны факту`, четыре графа `рыхлые` на 550–960 Б |
|
||||
| `node scripts/ratchets.mjs tighten` — в изолированном `git worktree` от этого SHA, с тем же `dist/` | да (ручная проверка ревьюером, исполнением) | опустил ровно четыре рыхлых потолка и `bundleBytes` до факта, ядра и точные метрики не тронул (файл не переписан, если число не изменилось); повторный `report` после этого показал «равен факту» по всем строкам |
|
||||
| `node scripts/mutation-gate.mjs --check` | да | exit 0, без FAIL; `browser guards: 200/200`; 3 предсуществующих WARN не относятся к #699 (`#650`, `${…}` в паттернах) |
|
||||
| `grep` литералов монолита в `test/ratchets.test.mjs` + `node --test test/monolith-text-anchors.test.mjs` | да | новый тест не задевает замороженный список `FROZEN_TEXT_ANCHOR_TESTS` (#624 AC4) — подтверждает, что первый упавший прогон `gate:small` автора («мой тест называл ядро строкой») действительно исправлен |
|
||||
| чтение `scripts/ratchets.mjs`, `scripts/bundle-budget.mjs`, `scripts/monolith-metrics.mjs`, `scripts/unused-locals-gate.mjs`, `scripts/mutation-registry.mjs`, `scripts/mutation-registry-check.mjs`, `scripts/release-prerelease.mjs`, `PROCESS.md`, `docs/TESTING.md` целиком | да | см. находки и раздел «проверено корректно» |
|
||||
|
||||
**Не прогонялось и почему:** полный дифф-мутационный прогон (`mutation-gate.mjs`
|
||||
с реальными патчами, 186 мутантов по заявлению автора) — трек show, Validate
|
||||
уже зелёный на этом SHA, а структурная проверка реестра (`--check`) чистая;
|
||||
браузерные смоки — тело issue их не называет, правка не трогает `src/**`,
|
||||
`node scripts/smoke-select.mjs` не запускался, так как нет диффа по фронтенду,
|
||||
который он мог бы сопоставить со смоками; `golden:verify`, `pytest
|
||||
tests_backend`, `npm run invariants`, performance-профили — ни один не
|
||||
применим (нет диффа по рендеру/Python/геометрии, AC их не называет).
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе, чинится в этой же задаче)
|
||||
|
||||
**M1. Новый шаг «опустить потолки на бете» не попал в единственный канонический
|
||||
release-runbook.** `docs/DEVELOPMENT.md` прямым текстом объявляет себя
|
||||
единственным домом release-механики («This section is the only home of the
|
||||
release mechanics»), и раздел «Primary prerelease path» — чек-лист, который
|
||||
реально читает релиз-менеджер перед публикацией кандидата (синхронизировать
|
||||
версии, changelog, `npm run bundle:release`, `RELEASE-NOTES.md`). В этом
|
||||
чек-листе нет ни слова про `node scripts/ratchets.mjs tighten`, хотя весь
|
||||
смысл #699 — в том, что «вторая сторона храповика» переехала именно сюда
|
||||
(`PROCESS.md` §8, `scripts/ratchets.mjs`: «закоммитить вместе с кандидатом
|
||||
беты»). Единственное место, где инструмент реально упомянут релиз-менеджеру —
|
||||
`release-prerelease.mjs`, вызывающий `ratchets.mjs report --warn` **внутри
|
||||
самой команды публикации**, то есть уже после того, как кандидат зафиксирован,
|
||||
провалидирован и запрошена публикация. К этому моменту откатывать «один
|
||||
коммит вместе с кандидатом» уже поздно — нужен отдельный коммит, а чек-лист
|
||||
подготовки кандидата про это ничего не говорит.
|
||||
|
||||
*Воспроизведение:* `grep -n "ratchets" docs/DEVELOPMENT.md` — пусто; раздел
|
||||
«Primary prerelease path» (`docs/DEVELOPMENT.md:516-560`) не содержит этого
|
||||
шага. `release-prerelease.mjs:511` вызывает `ratchets.mjs report --warn`
|
||||
только внутри `if (invokedDirectly)`, после `releaseView()` — то есть в момент
|
||||
публикации, не подготовки.
|
||||
|
||||
*Почему это не мелочь:* без записи в реальном runbook шаг будет забываться
|
||||
всегда, а не иногда — предупреждение видно только тому, кто уже нажал
|
||||
публикацию, и не гейтится ничем (`--warn` всегда возвращает 0). Потолки
|
||||
останутся рыхлыми навсегда, и половина заявленной пользы задачи (полоса
|
||||
считается от факта беты, а не от старой точки) не реализуется на практике.
|
||||
Это не гипотетический сценарий: на этом самом SHA `report` уже показывает
|
||||
четыре рыхлых графа и `bundleBytes` — если следующая бета выйдет без ручного
|
||||
запуска `tighten`, отсчёт полосы продолжится от чисел годичной давности.
|
||||
|
||||
*Что нужно:* добавить в чек-лист «Prepare the candidate as usual»
|
||||
(`docs/DEVELOPMENT.md`, раздел «Primary prerelease path») шаг
|
||||
`node scripts/ratchets.mjs tighten` перед коммитом кандидата — рядом с
|
||||
`npm run bundle:release`, до пуша и Validate.
|
||||
|
||||
### Low (снято ревьюером)
|
||||
|
||||
**L1. Оркестрация `tighten` (ветка `command === 'tighten'` в
|
||||
`scripts/ratchets.mjs`) не покрыта автотестом** — юниты покрывают только
|
||||
чистые функции (`readCoreCaps`/`rewriteCoreCaps`, `readConst`/`rewriteConst`,
|
||||
`ratchetState`, `ratchetRows`), а сама склейка (фильтрация строк по `kind`,
|
||||
сборка `coreFacts`/`baseline`, порядок трёх `writeFileSync`) — нет. Снимаю
|
||||
находку без возврата в задачу: проверил исполнением сам — в изолированном
|
||||
`git worktree` от `709c5b8a` с тем же `dist/`, что и в дереве, `tighten`
|
||||
опустил все четыре рыхлых графа и `bundleBytes`, не тронул уже точные ядра и
|
||||
метрики монолита, и результат подтверждён повторным `report` (все строки —
|
||||
«равен факту»). Это инструмент релиз-менеджера, запускаемый вручную раз в
|
||||
бету, с диффом трёх файлов, который человек обязан просмотреть перед
|
||||
коммитом (по инструкции инструмента) — риск непойманной регрессии невысокий и
|
||||
несоразмерен цене автотеста, пишущего в реальные файлы гейтов. Если в
|
||||
следующей бете `tighten` даст неверные числа — это будет видно в `git diff`
|
||||
до коммита.
|
||||
|
||||
## Проверено и корректно
|
||||
|
||||
- Полоса включительна с обеих сторон корректно и симметрично на всех трёх
|
||||
видах храповика: границы `cap+band`/`cap+band+1` для ядра, gzip-графов
|
||||
(initial View и ленивых) и чисел монолита проверены отдельными тестами по
|
||||
каждой стороне (`core-file-budget.test.mjs`, `bundle-assets.test.mjs`,
|
||||
`monolith-metrics.test.mjs`) — прогнаны, зелёные.
|
||||
- Снижение больше не красит ветку нигде из трёх мест
|
||||
(`initialViewCeilingViolation`/`lazyGraphCeilingViolation` больше не имеют
|
||||
ветки `kind: 'shrank'`; `compareWithBaseline`/`decide` печатают `info`, а не
|
||||
`FAIL`) — старый текст «Опустите потолок» вычищен из кода и тестов везде,
|
||||
где раньше проверялась двусторонность (проверено `grep` по всему дереву,
|
||||
ни одного мёртвого упоминания старой ветки).
|
||||
- Реордер в CLI `bundle-budget.mjs` (потолок беты проверяется до
|
||||
`assertBundleBudget`) не создаёт второй источник числа: `result` из
|
||||
`assertBundleBudget` и `manifest.initialViewGzipBytes`, использованный для
|
||||
проверки потолка, — один и тот же непреобразованный byte-count из манифеста
|
||||
(прочитано в исходнике: `assertBundleBudget` возвращает поля манифеста
|
||||
насквозь, без пересчёта). Правка действительно чинит баг, названный в
|
||||
сообщении Validate (`initial-view-ceiling-unplugged`): построил ветку в
|
||||
изолированном чтении — старый порядок (бюджет раньше потолка) при
|
||||
`ceiling+band` выше `INITIAL_VIEW_GZIP_BUDGET` действительно маскирует
|
||||
проверку потолка бюджетом.
|
||||
- Мутанты реестра (`scripts/mutation-registry.mjs`): 5 новых
|
||||
(`core-band-ignored`, `initial-view-band-below-ceiling-again`,
|
||||
`monolith-band-exact-again`, `monolith-shrink-fails-branch-again`,
|
||||
`ratchet-report-calls-band-tight`) и один перенацеленный
|
||||
(`monolith-metrics-baseline-strict`) — якоря найдены ровно по одному разу
|
||||
(`mutation-gate.mjs --check` зелёный), guard-паттерны совпадают с
|
||||
существующими именами тестов, `because` объясняет, какую регрессию мутант
|
||||
ловит. Мутант, из-за которого автор чинил `bundle-budget.mjs` во втором
|
||||
коммите (`initial-view-ceiling-unplugged`), обновлён на новое место якоря и
|
||||
по-прежнему ловится тем же тестом.
|
||||
- `mutation-registry-check.mjs`: превышение ориентира браузерных мутантов
|
||||
теперь инкрементирует `warned`, а не `stale` — код выхода определяется
|
||||
только `stale`, значит превышение больше не блокирует гейт, при этом
|
||||
строка предупреждения печатается и называет способ обоснования — ровно то,
|
||||
что решил владелец.
|
||||
- Первый провал автора на `gate:small` (`#624 AC4`, «мой тест называл ядро
|
||||
строкой») действительно исправлен: `test/ratchets.test.mjs` не содержит
|
||||
литералов `houseplan-card.ts`/`houseplan-editor-runtime.ts`/
|
||||
`houseplan-source.mjs`, `test/monolith-text-anchors.test.mjs` зелёный,
|
||||
список `FROZEN_TEXT_ANCHOR_TESTS` не тронут.
|
||||
- `PROCESS.md` §3 и §8, `docs/TESTING.md` описывают новое поведение
|
||||
согласованно с кодом: полоса, кто её опускает, где стена (`
|
||||
INITIAL_VIEW_GZIP_BUDGET` осталась абсолютной, это подтверждено и в CLI, и
|
||||
в тексте).
|
||||
- Число «потолок initial View» видно в тексте PROCESS.md/TESTING.md/ratchets.mjs
|
||||
и в самом коде ровно с одним источником — `INITIAL_VIEW_GZIP_CEILING` в
|
||||
`bundle-budget.mjs`; `ratchets.mjs` читает его оттуда же регэкспом, не
|
||||
дублирует.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный дифф-мутационный прогон `mutation-gate.mjs` с реальными патчами (186
|
||||
мутантов по заявлению автора, 6 шардов) — не перегонял; ограничился
|
||||
структурной проверкой `--check` и точечными юнитами по изменённым файлам.
|
||||
Трек show и зелёный Validate на этом SHA делают повторный полный прогон
|
||||
избыточным для ревью.
|
||||
- Браузерные смоки — не выбирались и не гонялись: диф не трогает `src/**` и
|
||||
ничего в demo/, `smoke-select.mjs` не запускался за отсутствием диффа,
|
||||
который он мог бы сопоставить.
|
||||
- `golden:verify`, `pytest tests_backend`, `npm run invariants`,
|
||||
performance-профили — не применимы к этому диффу (нет рендера, Python,
|
||||
геометрии; AC их не называет).
|
||||
- Не проверял, действительно ли `release:prerelease` печатает `--warn` в
|
||||
реальном GitHub Actions прогоне публикации (только прочитал код и юнит
|
||||
`test/ratchets.test.mjs`, который проверяет точную форму вызова текстом —
|
||||
не исполнением всей команды `release:prerelease`).
|
||||
|
||||
## Вердикт
|
||||
|
||||
Один Medium в скоупе (M1) без High → жёлтый, возврат автору. Один Low (L1)
|
||||
снят ревьюером с записью — проверен исполнением, дополнительной правки не
|
||||
требует.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/699-ratchet-bands`, коммит `709c5b8a38f4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `a18812c473f68a3fca17fb0b1b9df7909a219339`
|
||||
```
|
||||
git log --all --format='%H %T' | grep a18812c473f6
|
||||
```
|
||||
- Тело issue: `18b6f2dce7c2f914e9c4d61715360cb529fc34c87cbad12c2a646990fefcb724`
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,138 @@
|
||||
# CODE-REVIEW-699-r2
|
||||
|
||||
Issue: #699 · этап: code · трек: show · заход: r2 · блокирующих циклов до этого раунда: 1/2
|
||||
|
||||
Материал раунда: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`,
|
||||
вершина ветки `issue/699-ratchet-bands` = `23aca8a987142633c6f0c7522538d6601aa942d9`.
|
||||
Validate на этом SHA — success (run 36490355420). Ребейз на `dev` не делался
|
||||
(трек show, #696): `dev` впереди на 17 коммитов, слияние без конфликта
|
||||
(заявлено автором, `git merge-tree`); материал — ветка как есть.
|
||||
|
||||
Предыдущий раунд: r1, документ `docs/reviews/CODE-REVIEW-699-r1.md`, материал
|
||||
`709c5b8a38f4ac16f7ce18593ef0a40d672742cb`, вердикт жёлтый — один Medium в
|
||||
скоупе (M1), High 0.
|
||||
|
||||
## Дельта r1 → r2
|
||||
|
||||
`git diff 709c5b8a..HEAD` — два коммита: `d7af9fbb` (только добавляет
|
||||
`docs/reviews/CODE-REVIEW-699-r1.md`, документ ревью, не код) и `23aca8a9`
|
||||
(исправление M1). Дифф второго коммита — три файла:
|
||||
|
||||
```
|
||||
docs/DEVELOPMENT.md | 5 ++++-
|
||||
scripts/mutation-registry.mjs | 11 +++++++++++
|
||||
test/ratchets.test.mjs | 8 ++++++++
|
||||
```
|
||||
|
||||
Разбор ниже — по этой дельте (§2.10): дельта локальна (один Medium из
|
||||
предыдущего раунда, три файла), задача не меняет контракт и не вводит новую
|
||||
подсистему, повторный ребейз не проводился.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| **M1**: `node scripts/ratchets.mjs tighten` не был в единственном каноническом release-runbook `docs/DEVELOPMENT.md` (§«Primary prerelease path»); единственное упоминание — предупреждение внутри `release:prerelease` уже в момент публикации, когда откатывать коммит кандидата поздно. | Шаг `tighten` вставлен в чек-лист «Prepare the candidate as usual» сразу после `npm run bundle:release` и до записи `docs/RELEASE-NOTES.md`, с пояснением «commit them with the candidate». Закрытие подкреплено тестом, который пере проверяет и наличие, и порядок шага, плюс новым мутантом реестра. | `docs/DEVELOPMENT.md:525-534` (см. ниже цитату); `test/ratchets.test.mjs:69-75` (`#699 r1 M1: runbook беты опускает храповики при подготовке кандидата, до публикации`); `scripts/mutation-registry.mjs` — `release-runbook-forgets-tighten`, guard `--test-name-pattern="#699 r1 M1"`. |
|
||||
|
||||
Цитата из `docs/DEVELOPMENT.md:525-531` (после правки):
|
||||
|
||||
> Prepare the candidate as usual: synchronize every version field, add dated RU
|
||||
> and EN changelog sections, update the production bundle snapshots with
|
||||
> `npm run bundle:release` (…), then lower the ratchets to the candidate's
|
||||
> facts with `node scripts/ratchets.mjs tighten` (#699: it rewrites the core line
|
||||
> caps, the gzip graph ceilings and `scripts/monolith-baseline.json` from the
|
||||
> fresh `dist/`; commit them with the candidate) and write the short bilingual
|
||||
> body in `docs/RELEASE-NOTES.md`.
|
||||
|
||||
Порядок ровно тот, который требовало замечание: `tighten` — после свежего
|
||||
`dist/` (`bundle:release`) и до пуша/публикации (`release:prerelease`), одним
|
||||
коммитом кандидата.
|
||||
|
||||
**L1** (r1, оркестрация `tighten` без автотеста) была снята ревьюером в r1 с
|
||||
записью; дельта r2 её не касается — унаследовано без повторной проверки.
|
||||
|
||||
## Проверка исполнением (не только чтением)
|
||||
|
||||
- `node --test test/ratchets.test.mjs` — 6/6 ok, включая новый тест `#699 r1
|
||||
M1`.
|
||||
- Тест умеет падать: применил патч мутанта вручную (`tighten` → `report` в той
|
||||
же позиции текста, ровно то, что делает `release-runbook-forgets-tighten`),
|
||||
прогнал `node --test --test-name-pattern="#699 r1 M1" test/ratchets.test.mjs`
|
||||
— **1 fail** (`assert.match` не находит `tighten` после `bundle:release`).
|
||||
Откатил патч (`git status --porcelain` — чисто, `git diff` — пусто).
|
||||
- `node scripts/mutation-gate.mjs --check` — зелёный; `release-runbook-forgets-tighten`
|
||||
в выводе — ровно один раз `ok` (якорь не задваивается, реестр не
|
||||
рассинхронизирован), `browser guards: 200/200`, 3 предсуществующих WARN не
|
||||
относятся к #699 (те же, что в r1).
|
||||
- `node --test test/mutation-gate.test.mjs test/monolith-text-anchors.test.mjs`
|
||||
— 71/71 ok: новый тест не задевает замороженный список
|
||||
`FROZEN_TEXT_ANCHOR_TESTS`, регресс первого провала автора (`#624 AC4`) не
|
||||
вернулся.
|
||||
- `node --test test/process-digests.test.mjs` — 5/5 ok (дифф r2 не трогает
|
||||
`PROCESS.md`/`REVIEWER.md`, ожидаемо зелёный).
|
||||
- Трейлеры коммита `23aca8a9`: `Issue: #699`, `User-Visible: no` — верно,
|
||||
правка не трогает `src/**` и видимое поведение продукта не меняет (это
|
||||
правка релиз-документации и внутреннего гейта). Оба changelog не тронуты —
|
||||
корректно при `User-Visible: no`.
|
||||
- Рабочая копия после ручной проверки мутанта осталась чистой:
|
||||
`git status --porcelain` пуст, файлов в репозитории не создавал.
|
||||
|
||||
## Проверено и корректно (унаследовано из r1, без повторного прогона)
|
||||
|
||||
Не в скоупе дельты r2 — реализация правила «полоса над потолком беты» для
|
||||
всех четырёх храповиков, реордер CLI `bundle-budget.mjs`, мутанты реестра,
|
||||
согласованность `PROCESS.md`/`docs/TESTING.md` с кодом. Принято как в r1, без
|
||||
повторной проверки — документ и материал: `docs/reviews/CODE-REVIEW-699-r1.md`,
|
||||
SHA `709c5b8a38f4ac16f7ce18593ef0a40d672742cb`. Дельта r2 этих файлов не
|
||||
касается (см. `git diff 709c5b8a..HEAD` выше — только `docs/DEVELOPMENT.md`,
|
||||
`scripts/mutation-registry.mjs`, `test/ratchets.test.mjs`).
|
||||
|
||||
## Находки
|
||||
|
||||
Нет ни одной новой находки. M1 закрыт доказательно (тест + мутант,
|
||||
исполнением подтверждено, что тест умеет падать). Новых Medium/Low в дельте не
|
||||
обнаружено:
|
||||
- Место вставки шага в чек-листе логически верное (после свежего `dist/`, до
|
||||
публикации), формулировка не создаёт второго источника числа (шаг ссылается
|
||||
на `#699`, не вводит новых констант).
|
||||
- Мутант `release-runbook-forgets-tighten` вставлен в середину реестра без
|
||||
видимых конфликтов id/anchor, гейт `--check` зелёный.
|
||||
|
||||
## Что не проверял
|
||||
|
||||
- Полный дифф-мутационный прогон (`mutation-gate.mjs` с реальными патчами,
|
||||
186 мутантов, 6 шардов) — не перегонял повторно: трек show, Validate уже
|
||||
зелёный на этом SHA, дельта r2 — три файла из документации/гейта, точечная
|
||||
проверка нового мутанта исполнением сделана вручную (см. выше).
|
||||
- Браузерные смоки — не выбирались и не гонялись: дельта не трогает `src/**`
|
||||
и demo/, `smoke-select.mjs` не запускался за отсутствием диффа, который он
|
||||
мог бы сопоставить.
|
||||
- `golden:verify`, `pytest tests_backend`, `npm run invariants`,
|
||||
performance-профили — не применимы к этому диффу (нет рендера, Python,
|
||||
геометрии; AC их не называют).
|
||||
- Не проверял исполнением, что реальный `release:prerelease` в GitHub Actions
|
||||
печатает `--warn` — унаследовано из r1 как непроверенное (эта часть дельтой
|
||||
r2 не затронута).
|
||||
- Типизацию, полный `npm test` и `npm run build` с сверкой бандла — не
|
||||
перегонял: зелёный Validate на этом же SHA (`23aca8a9`, run 36490355420)
|
||||
подтверждает дешёвые гейты без повторного прогона.
|
||||
|
||||
## Вердикт
|
||||
|
||||
M1 из r1 закрыт: шаг `tighten` в единственном каноническом release-runbook,
|
||||
до публикации, зафиксирован тестом, который умеет падать, и новым мутантом
|
||||
реестра. Новых находок нет. High: 0, Medium: 0 → зелёный, цикл не тратится.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/699-ratchet-bands`, коммит `23aca8a98714` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `d7500b073db5a6a6c55130d61acfd14cd9f8c715`
|
||||
```
|
||||
git log --all --format='%H %T' | grep d7500b073db5
|
||||
```
|
||||
- Тело issue: `18b6f2dce7c2f914e9c4d61715360cb529fc34c87cbad12c2a646990fefcb724`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,174 @@
|
||||
# CODE-REVIEW-699-r3
|
||||
|
||||
Issue: #699 · этап: code · трек: show · заход: r3 · блокирующих циклов до этого раунда: 1/2
|
||||
|
||||
Материал раунда: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`,
|
||||
вершина ветки `issue/699-ratchet-bands` = `4fa3c7977ebbd11907364af626f978deec790aa5`.
|
||||
Validate на этом SHA — success (run 36491631358). Рабочая копия уже на этом SHA;
|
||||
`git fetch`/`checkout` не делал.
|
||||
|
||||
Предыдущий раунд: r2, документ `docs/reviews/CODE-REVIEW-699-r2.md`, материал
|
||||
`23aca8a987142633c6f0c7522538d6601aa942d9`, вердикт **зелёный** (High 0, Medium 0).
|
||||
|
||||
## Почему r3, если r2 был зелёным
|
||||
|
||||
Пока шло ревью r2, `dev` продвинулся на 17 коммитов (влиты #696–#698, #701,
|
||||
#702). Владелец опубликовал кандидат `4fa3c797` как ребейз того же дерева на
|
||||
новый `dev@e1700757` и сам заявил: «Дифф изменился при ребейзе… материал ревью
|
||||
`23aca8a9` и кандидат `4fa3c797` дают разные patch-id: соседние правки в `dev`
|
||||
изменили содержимое патча» (issue-комментарий 22:18:47, ссылка на §2.10, #492).
|
||||
Зелёный вердикт цикл не тратит, но материал под ним умер (объекты `23aca8a9` и
|
||||
дерево `d7500b073db5` не резолвятся в этом дереве — ожидаемо после ребейза,
|
||||
`git cat-file -t` возвращает «not found», это не находка, см. REVIEWER.md
|
||||
«Повторный раунд»), поэтому конвейер честно открыл новый заход, а не подтянул
|
||||
старый вердикт под новый SHA (#312).
|
||||
|
||||
**Проверка гипотезы «дифф на самом деле не изменился».** Я не могу сравнить
|
||||
деревья байт-в-байт (старые объекты собраны сборщиком мусора), поэтому сверил
|
||||
по содержимому: прочитал целиком `git diff origin/dev...HEAD` (19 файлов,
|
||||
856+/146-) и построчно сопоставил с описанием кода в r1/r2 (таблицы находок,
|
||||
цитаты кода, номера тестов). Результат — по каждому файлу код и тесты
|
||||
текстуально совпадают с тем, что описывали r1/r2:
|
||||
`initialViewCeilingViolation`/`lazyGraphCeilingViolation` без ветки `shrank`,
|
||||
`compareWithBaseline` с `METRIC_BANDS`, CLI `bundle-budget.mjs` с потолком
|
||||
беты до `assertBundleBudget`, `mutation-registry-check.mjs` — `warned` вместо
|
||||
`stale`, новый `scripts/ratchets.mjs` (`report`/`tighten`), шаг `tighten` в
|
||||
`docs/DEVELOPMENT.md` («Prepare the candidate as usual», сразу после
|
||||
`npm run bundle:release`) — ровно формулировка, которую цитировал r2. Значит
|
||||
patch-id разошёлся не из-за смыслового отличия, а из-за смещения контекстных
|
||||
строк при повторном ребейзе (PROCESS.md/докс, которые правили #701 и другие
|
||||
соседи, лежат рядом с местом вставки #699). Разбор ниже — по этой дельте: она
|
||||
не локальна формально (ребейз на ушедший вперёд `dev`, §2.10), поэтому я
|
||||
перечитал файлы целиком и перепрогнал тесты сам (см. «Как проверялось»), а не
|
||||
ограничился таблицей «чем закрыта» — но новых находок это не даёт, потому что
|
||||
менять было нечего.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Не изменился с r1 (см. `docs/reviews/CODE-REVIEW-699-r1.md`, «Скоуп»):
|
||||
инфраструктурная задача, файлов класса A нет, `src/**` не тронут. Двусторонние
|
||||
храповики с нулевым запасом заменены на полосу над потолком последней беты
|
||||
(строки двух ядер, gzip четырёх графов, шесть чисел монолита, лимит браузерных
|
||||
мутантов из жёсткого — в ориентир); `scripts/ratchets.mjs` — инструмент
|
||||
релиз-менеджера (`report`/`tighten`); шаг `tighten` закреплён в единственном
|
||||
каноническом release-runbook (`docs/DEVELOPMENT.md`) — это и есть M1 из r1,
|
||||
закрытый в r2. Оба открытых вопроса ТЗ решены владельцем (полоса ядра = 50,
|
||||
лимит мутантов — ориентир) и реализованы теми же числами.
|
||||
|
||||
`User-Visible: no` на всех четырёх коммитах (`47bdf883`, `44d4ff4f`, `56ebddf3`
|
||||
и двух документных `61feca1f`/`bc15c3c9`/`4fa3c797`) — верно, продуктовое
|
||||
поведение (`src/**`) не меняется; changelog не тронут, и это корректно при
|
||||
`no` (проверено — `git diff origin/dev...HEAD --stat -- docs/CHANGELOG.md
|
||||
docs/CHANGELOG.ru.md` пуст). Трейлеры `Issue: #699` / `User-Visible: no`
|
||||
подтверждены на всех трёх кодовых коммитах (`git show -s --format=... | grep`).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты подтверждены зелёным Validate на этом же SHA (`4fa3c797`, run
|
||||
36491631358) — `typecheck`, `npm test`, `npm run build` + bundle-policy не
|
||||
перегонялись повторно. Поверх этого, по диффу и лично мной на этом SHA:
|
||||
|
||||
| Гейт | Прогнан | Результат |
|
||||
|---|---|---|
|
||||
| `node --test test/ratchets.test.mjs test/core-file-budget.test.mjs test/monolith-metrics.test.mjs test/bundle-assets.test.mjs test/mutation-gate.test.mjs test/process-digests.test.mjs test/monolith-text-anchors.test.mjs` | да, лично | 133/133 ok |
|
||||
| `node scripts/mutation-gate.mjs --check` | да, лично | зелёный; `browser guards: 200/200`; 3 предсуществующих WARN (не относятся к #699, те же, что в r1/r2) |
|
||||
| Защитный AC M1 — «тест умеет падать» перепроверен на этом SHA, не только унаследован | да, лично, исполнением | заменил в `docs/DEVELOPMENT.md` `tighten` → `report` в той же позиции (патч мутанта `release-runbook-forgets-tighten`), `node --test --test-name-pattern="#699 r1 M1" test/ratchets.test.mjs` → **1 fail** (`assert.match` не находит `tighten` после `bundle:release`), откатил (`git checkout -- docs/DEVELOPMENT.md`), `git status --porcelain` — пусто |
|
||||
| Построчное сопоставление `git diff origin/dev...HEAD` (19 файлов) с описанием кода в r1/r2 | да, лично, чтением | текст диффа совпадает с цитатами и таблицами r1/r2 файл за файлом — см. «Почему r3» выше |
|
||||
| `git diff origin/dev...HEAD --stat -- docs/CHANGELOG.md docs/CHANGELOG.ru.md` | да | пусто — корректно при `User-Visible: no` |
|
||||
| Трейлеры `Issue`/`User-Visible` на `47bdf883`, `44d4ff4f`, `56ebddf3` | да, лично | все три — `Issue: #699`, `User-Visible: no` |
|
||||
| `docs/reviews/INDEX.md` (r1/r2 уже проиндексированы) | да, прочитал | строки #699 r1 (🟡, M1) и r2 (🟢) на месте, согласуются с документами |
|
||||
|
||||
**Не прогонялось и почему:** полный дифф-мутационный прогон (`mutation-gate.mjs`
|
||||
с реальными патчами, 186 мутантов) — трек show, зелёный Validate на этом SHA,
|
||||
структурная `--check` чистая, и содержимое диффа не изменилось с r1/r2, где
|
||||
этот прогон уже проводился (186/186); браузерные смоки — тело issue их не
|
||||
называет, диф не трогает `src/**`/`demo/`, `smoke-select.mjs` не запускался за
|
||||
отсутствием сопоставимого диффа; `golden:verify`, `pytest tests_backend`,
|
||||
`npm run invariants`, performance-профили — не применимы (нет рендера, Python,
|
||||
геометрии; AC их не называют); не проверял исполнением реальный
|
||||
`release:prerelease` в GitHub Actions — унаследовано из r1/r2 как непроверенное
|
||||
(эта часть дельтой r3 не затронута); `npx tsc --noEmit`/`npm test` целиком/
|
||||
`npm run build` с трёхсторонней сверкой бандла — не перегонял: зелёный Validate
|
||||
на этом же SHA подтверждает дешёвые гейты без повтора.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| **M1**: `node scripts/ratchets.mjs tighten` не был в единственном каноническом release-runbook `docs/DEVELOPMENT.md`; единственное упоминание — предупреждение внутри `release:prerelease` уже в момент публикации. | Шаг `tighten` в чек-листе «Prepare the candidate as usual» сразу после `npm run bundle:release`, до `docs/RELEASE-NOTES.md`/публикации; тест `#699 r1 M1` пере-проверяет и наличие, и порядок; мутант `release-runbook-forgets-tighten`. Перепроверено мной на `4fa3c797`: мутант применён вручную → тест падает (1 fail) → откат чистый. | `docs/DEVELOPMENT.md:525-534` (`Prepare the candidate as usual` → `bundle:release` → `ratchets.mjs tighten (#699 …) commit them with the candidate` → `RELEASE-NOTES.md`); `test/ratchets.test.mjs:69-75`; `scripts/mutation-registry.mjs` — `release-runbook-forgets-tighten`. |
|
||||
|
||||
Низкая находка **L1** (r1, оркестрация `tighten` без автотеста) была снята
|
||||
ревьюером в r1 с записью (проверено исполнением в изолированном worktree);
|
||||
дельта r2 и r3 её не касаются.
|
||||
|
||||
## Унаследовано из r1/r2 (без повторной проверки по существу)
|
||||
|
||||
Симметричность полосы по обеим границам на всех трёх видах храповика (ядра,
|
||||
gzip-графы, числа монолита), корректность реордера в CLI `bundle-budget.mjs`
|
||||
(потолок беты проверяется до `assertBundleBudget`, один источник байт-каунта),
|
||||
пять новых мутантов реестра + один перенацеленный (якоря найдены по одному
|
||||
разу), согласованность `PROCESS.md §8`/`docs/TESTING.md` с кодом, «одно число —
|
||||
один источник» у потолка initial View (`INITIAL_VIEW_GZIP_CEILING` в
|
||||
`bundle-budget.mjs`, `ratchets.mjs` читает его же регэкспом) — всё это разобрано
|
||||
полно в `docs/reviews/CODE-REVIEW-699-r1.md` (материал `709c5b8a38f4`) и не
|
||||
затронуто дельтой r1→r2→r3 текстуально (см. «Почему r3» выше — построчное
|
||||
сопоставление). Принимаю без повторного вывода, кроме того, что перепроверил
|
||||
сам и назвал в таблице выше (юниты, `mutation-gate --check`, M1-мутант).
|
||||
|
||||
## Находки
|
||||
|
||||
Новых находок нет. High: 0, Medium: 0.
|
||||
|
||||
Формальный повод для r3 (смена patch-id при повторном ребейзе, #492) не
|
||||
породил содержательного отличия: диф `origin/dev...HEAD` на этом SHA
|
||||
текстуально совпадает с тем, что рецензировали r1 и r2, тесты и мутационный
|
||||
гейт зелёные лично на этом SHA, а M1 остаётся доказуемо закрытым (мутант
|
||||
всё ещё ловится).
|
||||
|
||||
## Проверено и корректно
|
||||
|
||||
- Продуктовый код (`src/**`) не тронут — задача инфраструктурная, `User-Visible:
|
||||
no` верно на всех коммитах, changelog не трогается, и это правильно.
|
||||
- Рабочая копия чистая до и после моей проверки мутанта (`git status
|
||||
--porcelain` — пусто оба раза), файлов в репозитории я не создавал (документ
|
||||
ревью пишется вне репозитория, в `$REVIEW_DOC`).
|
||||
- Тесты, гейты и трейлеры на текущем SHA `4fa3c797` дают тот же результат, что
|
||||
описывали r1/r2 на своих (уже нерезолвящихся) SHA — расхождения нет.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный дифф-мутационный прогон `mutation-gate.mjs` с реальными патчами (186
|
||||
мутантов, 6 шардов) — не перегонял на этом SHA: он уже был выполнен в r1 на
|
||||
байт-идентичном по содержанию диффе, трек show, Validate зелёный.
|
||||
- Браузерные смоки — диф не трогает `src/**`/`demo/`, `smoke-select.mjs` не
|
||||
запускался за отсутствием сопоставимого диффа.
|
||||
- `golden:verify`, `pytest tests_backend`, `npm run invariants`,
|
||||
performance-профили — не применимы (нет рендера, Python, геометрии; AC их не
|
||||
называют).
|
||||
- Исполнение реального `release:prerelease` в GitHub Actions (печатает ли
|
||||
`--warn` на практике) — унаследовано из r1/r2 как непроверенное.
|
||||
- `npx tsc --noEmit`, `npm test` целиком, `npm run build` с трёхсторонней
|
||||
сверкой бандла — не перегонял: зелёный Validate на `4fa3c797` (run
|
||||
36491631358) подтверждает дешёвые гейты без повтора.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Формальный r3 без содержательной дельты: рёбейз сменил patch-id, но не код.
|
||||
M1 из r1 остаётся закрытым и перепроверен исполнением на текущем SHA. Новых
|
||||
находок нет. High: 0, Medium: 0 → зелёный, цикл не тратится.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/699-ratchet-bands`, коммит `4fa3c7977ebb` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `3dfcbd4230f3cf2b19d814176b2b5887dbd30564`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 3dfcbd4230f3
|
||||
```
|
||||
- Тело issue: `18b6f2dce7c2f914e9c4d61715360cb529fc34c87cbad12c2a646990fefcb724`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,184 @@
|
||||
# CODE-REVIEW-700-r1
|
||||
|
||||
Issue: #700 · Предполёт не красит ветку задачи чужими причинами: зеркало workflow и внешние ссылки
|
||||
Этап: code · Заход: r1 · Трек: show · Материал: `89d57e054cdf940d3ead83ec509075164118ee3b` (один коммит поверх `origin/dev` @ `c716bb0f63104f5afb9d660bc058e095256d433d`)
|
||||
|
||||
## Скоуп
|
||||
|
||||
Инфраструктурная задача трека `show`: предполёт `validate.yml` не должен красить
|
||||
ветку задачи двумя внешними по отношению к её диффу причинами —
|
||||
|
||||
1. расхождением зеркала тонких вызывающих workflow (`main` vs `dev`);
|
||||
2. упавшим внешним сайтом при проверке ссылок в документации (`check-docs.mjs`).
|
||||
|
||||
Оба пункта на ветках `issue/*` становятся предупреждением в сводке, не красят
|
||||
вердикт; на push в `dev` (там, где кандидат беты и релиз фактически и
|
||||
проверяются — см. ниже) поведение остаётся прежним, красным. Плюс — на push в
|
||||
`dev` расхождение зеркала автоматически заводит одно owner-issue
|
||||
`[workflow-sync]` (или комментирует уже открытое), по образцу ночного
|
||||
мутационного гейта (#472).
|
||||
|
||||
Файлы: `.github/workflows/validate.yml`, `scripts/check-docs.mjs`,
|
||||
`scripts/mutation-registry.mjs`, `PROCESS.md` §10.4,
|
||||
`test/validate-workflow.test.mjs`, `test/docs-freshness.test.mjs`,
|
||||
`test/classify-changes.test.mjs`. Файлов класса A/D нет — согласен с автором.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты (typecheck/test/build/bundle-policy) подтверждены зелёным
|
||||
Validate на этом же SHA (workflow_dispatch, `issue/700-preflight-warnings`,
|
||||
run `36482200722`, `conclusion: success`) — не перегонялись повторно.
|
||||
|
||||
Дополнительно к зелёному Validate прогнал сам:
|
||||
|
||||
| Гейт | Результат |
|
||||
|---|---|
|
||||
| `node --test test/validate-workflow.test.mjs` | 27/27 зелёных |
|
||||
| `node --test test/classify-changes.test.mjs` | 21/21 зелёных |
|
||||
| `node --test test/docs-freshness.test.mjs` | 3/3 зелёных |
|
||||
| `node --test test/mutation-gate.test.mjs` (структура реестра + исполнение guard-команд обоих новых мутантов среди прочих) | 69/69 зелёных |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)» — браузерные смоки нечего выбирать, не пропуск |
|
||||
| Мутация вручную: `advise` → `check` в `.github/workflows/validate.yml`, тест `#700: на ветке задачи…` | падает (`1 fail`), восстановлено |
|
||||
| Мутация вручную: `externalErrors = EXTERNAL_WARN ? warnings : errors` → `= errors` в `scripts/check-docs.mjs`, тест `#700: check-docs…` и `docs-freshness.test.mjs` | оба падают, восстановлено |
|
||||
|
||||
Также прочитал живой лог `run 36482200722`, job «Предполёт»: на этом прогоне
|
||||
`REF=refs/heads/issue/700-preflight-warnings` реально прошёл веткой
|
||||
`--external=warn` (`скриншоты документации: режим warn` →
|
||||
`Documentation checks passed (7 files, 12 external links)`), а шаг «Вердикт
|
||||
предполётных проверок» реально выполнил `task_branch=true` и напечатал
|
||||
`ok тонкие вызывающие workflow в main и dev` через `advise` (все шесть
|
||||
файлов зеркала совпали, поэтому и `advise`, и `check` в этом прогоне дали бы
|
||||
одинаковый «ok» — само разветвление логики живьём не проверено на красном
|
||||
входе, это закрыто юнит-тестами и ручной мутацией выше). Шаг «Расхождение
|
||||
зеркала на dev — issue владельцу» на этом прогоне не выполнялся (условие
|
||||
`push && refs/heads/dev` не совпало) — ожидаемо, не тестировался живьём,
|
||||
только юнит-тестом на структуру шага.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе) — `gh issue list` fail-open вместо fail-safe в новом шаге создания issue
|
||||
|
||||
`.github/workflows/validate.yml`, шаг «Расхождение зеркала на dev — issue
|
||||
владельцу»:
|
||||
|
||||
```bash
|
||||
existing=$(gh issue list --repo "$REPO" --state open --search "\"$marker\" in:title" \
|
||||
--json number,title --jq '[.[] | select(.title | startswith("[workflow-sync]"))][0].number // empty' || true)
|
||||
if [ -n "$existing" ]; then
|
||||
gh issue comment "$existing" ...
|
||||
exit 0
|
||||
fi
|
||||
gh issue create --repo "$REPO" --title "$marker ..." ...
|
||||
```
|
||||
|
||||
`|| true` глушит именно **чтение** списка issue: если `gh issue list` падает
|
||||
(сетевой сбой, рейт-лимит), `existing` остаётся пустой строкой, и скрипт идёт
|
||||
в ветку `gh issue create` — заводит новое `[workflow-sync]` issue, даже если
|
||||
открытое уже есть. Это расходится и с заявленным контрактом задачи («если оно
|
||||
уже открыто, в него добавляется комментарий»/PROCESS.md §10.4 «одно issue»),
|
||||
и с намерением автора («Сбой API — предупреждение: шаг сообщает, а не
|
||||
судит») — фактическое поведение при сбое чтения не «сообщает», а действует
|
||||
(плодит дубликат).
|
||||
|
||||
Сравнение с прецедентом, на который автор явно ссылается (#472,
|
||||
`.github/workflows/_mutation-gate.yml:327`): та же операция там **без**
|
||||
`|| true` — `gh issue list` без защиты, при её падении шаг GitHub Actions
|
||||
(shell по умолчанию `bash -eo pipefail`) прерывается целиком, дубликат не
|
||||
заводится. Новый шаг в #700 сознательно ослабил именно эту защиту.
|
||||
|
||||
**Чем краснеет:** мутация `|| true` → убрать (или заменить на
|
||||
`|| { echo "::warning::не удалось проверить открытые issue — issue не
|
||||
заводится"; exit 0; }`) — тест на этот путь в диффе отсутствует, поэтому
|
||||
проверено чтением, не исполнением; воспроизвести можно, подменив `gh` в PATH
|
||||
шага на скрипт, который для `issue list` возвращает ненулевой код, — тогда
|
||||
следующий `gh issue create` реально уйдёт, хотя нужного issue список не нашёл
|
||||
не потому, что его нет, а потому что сам не смог проверить.
|
||||
|
||||
**Почему Medium, не High:** окно узкое (нужен сбой именно у `gh issue list`,
|
||||
а не у последующих команд), шаг не участвует в вердикте (`check`/`advise` его
|
||||
исход не читают), последствие — лишнее owner-facing issue, а не красный
|
||||
Validate и не пропущенное реальное расхождение. Но это дефект поведения,
|
||||
прямо противоречащий описанному в задаче и в PROCESS.md контракту «одно
|
||||
issue», поэтому не Low: правка тривиальна (одна строка) и находится в этом
|
||||
же диффе, значит чинится в этом же issue (§3 п.8, §2.7), не заводится
|
||||
отдельно.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Блокирующий периметр не сузился там, где должен остаться красным.**
|
||||
«Кандидат беты» в этом репозитории — не ветка `issue/*`, а коммит с
|
||||
трейлером `Release:` (PROCESS.md, класс D) либо `workflow_dispatch` тега в
|
||||
`release.yml`, оба целятся в `dev`/точный SHA, не в `issue/*`-ветку — значит
|
||||
`task_branch=false`, и `check` (красный) для workflow_sync и `--external`
|
||||
(без `warn`) для внешних ссылок продолжают действовать на этих путях.
|
||||
Отдельно проверил: «кандидат слияния» (`merge-candidate.mjs`) действительно
|
||||
публикуется push’ем в саму ветку задачи (PROCESS.md §… «публикация
|
||||
кандидата в ветку задачи») и там расхождение зеркала будет предупреждением —
|
||||
но финальный гейт слияния всё равно упирается в push **в `dev`** с
|
||||
`--force-with-lease`, а этот push уже не `issue/*`, и там проверка красная
|
||||
по-прежнему. Сквозной дыры в периметре нет.
|
||||
- `validate.yml` не входит в список из шести тонких зеркалируемых файлов
|
||||
(`process.yml`, `mutation-gate.yml`, `process-resume.yml`, `nightly.yml`,
|
||||
`process-reconcile.yml`, `process-metrics.yml`,
|
||||
`test/default-branch-workflows.test.mjs`) — этому диффу зеркалирование в
|
||||
`main` не требуется, сам он не самозеркалируемый файл.
|
||||
- `check-docs.mjs`: `EXTERNAL_WARN` корректно разводит `externalErrors` на
|
||||
`warnings`/`errors`, не создавая двойного пуша в оба стока; выход по
|
||||
`errors.length` не тронут — предупреждения никогда не красят `exitCode`.
|
||||
`transientHosts` (уже существовавший путь всегда-предупреждение) не задет.
|
||||
- Трейлеры коммита: `Issue: #700`, `User-Visible: no` — корректно, поведение
|
||||
для конечного пользователя карточки не меняется (это только CI/процесс),
|
||||
изменений в `docs/USER-GUIDE.ru.md` не требуется и не сделано.
|
||||
- Одно число, один источник (§8): числовых значений, видимых пользователю
|
||||
карточки, дифф не вводит и не дублирует — весь дифф про CI-вердикт.
|
||||
- Тесты умеют падать: обе новые мутации в `scripts/mutation-registry.mjs`
|
||||
(`task-branch-workflow-sync-red-again`, `external-link-warn-mode-ignored`)
|
||||
проверены вручную — соответствующие guard-тесты красные на мутированном
|
||||
коде, зелёные на исходном (см. таблицу гейтов выше).
|
||||
- `PROCESS.md` §10.4 дополнен точно тем, что реализовано: явно назван список
|
||||
из шести файлов, явно — что `performance.yml` в него не входит (не
|
||||
тронуто), явно — что на ветке задачи это предупреждение, а блокирует
|
||||
`dev`/кандидат/релиз.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- `actionlint validate.yml` — заявлен автором, локально бинарник недоступен
|
||||
(`command not found`); не перепроверял. Косвенное подтверждение — сам YAML
|
||||
реально исполнился на CI (run `36482200722`) без ошибки парсинга workflow.
|
||||
- `mutation-gate --check` (полный) и весь `npm run gate:small` — не
|
||||
перегонял: дешёвые гейты уже зелёные на этом SHA (см. ссылку в задаче
|
||||
ревью), а трек `show` не запрашивает мутанты по диффу (#696) — их
|
||||
отсутствие в этом отчёте не находка.
|
||||
- Ветвь `pull_request`/иных не-`issue/*`, не-`dev` рефов (например,
|
||||
гипотетический feature-branch без префикса `issue/`) — в этом репозитории
|
||||
такие пуши процессом не предусмотрены (`pre-push` гейт + прямые пуши,
|
||||
PR-флоу не используется по факту виденных коммитов), поэтому не разбирал
|
||||
отдельно; если он всё же встретится, `task_branch=false` и поведение
|
||||
останется прежним (строгим) — то есть ничего не ослабляется по умолчанию.
|
||||
- Живое воспроизведение красного `workflow_sync` (реальное расхождение
|
||||
`main`/`dev`) и живое исполнение шага создания issue — не запускал (это
|
||||
разрушительное действие: реально завело бы issue в репозитории); логика
|
||||
закрыта юнит-тестами и ручными мутациями, шаг создания issue — только
|
||||
чтением кода плюс находкой выше.
|
||||
- `golden:verify`, `pytest tests_backend`, инварианты модели, performance —
|
||||
не применимо, дифф не трогает `src/**`, рендер, геометрию или Python.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка — Medium, в скоупе задачи, с указанной строкой и
|
||||
минимальной правкой. По правилам трека без High это жёлтый вердикт и возврат
|
||||
автору в этом же issue.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/700-preflight-warnings`, коммит `89d57e054cdf` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `945dad8823268ab0bbce926bd9ffe5e71d1f6cb2`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 945dad882326
|
||||
```
|
||||
- Тело issue: `a0c40d40c1401844c3d04ff0695dd347e76172d990004ea2799cc4df38729fce`
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,148 @@
|
||||
# CODE-REVIEW-700-r2
|
||||
|
||||
Issue: #700 · Предполёт не красит ветку задачи чужими причинами: зеркало workflow и внешние ссылки
|
||||
Этап: code · Заход: r2 · Трек: show · Материал: `ed3d63e20db24fe9172c83f7ea2e824c90444dcc`
|
||||
(коммит `ed3d63e2` поверх материала r1 `89d57e054cdf940d3ead83ec509075164118ee3b`,
|
||||
который сам лежит поверх `origin/dev` @ `c716bb0f63104f5afb9d660bc058e095256d433d`;
|
||||
на момент этого раунда `origin/dev` ушёл на 8 коммитов вперёд, слияние без
|
||||
конфликта — по треку `show` ветка к `dev` не приводится, материал — ветка как есть).
|
||||
|
||||
## Скоуп раунда
|
||||
|
||||
Единственная цель r2 — закрыть единственную находку r1 (Medium, в скоупе):
|
||||
`gh issue list … || true` в шаге «Расхождение зеркала на dev — issue
|
||||
владельцу» (`.github/workflows/validate.yml`) глушил сбой чтения списка
|
||||
открытых issue в пустую строку и проваливался в `gh issue create`, заводя
|
||||
дубликат `[workflow-sync]` даже когда нужное issue уже открыто, но недоступно
|
||||
для чтения (сеть, рейт-лимит).
|
||||
|
||||
Дельта r1→r2 — ровно один коммит `ed3d63e2`, три файла:
|
||||
|
||||
```
|
||||
.github/workflows/validate.yml | 9 +++++++--
|
||||
scripts/mutation-registry.mjs | 11 +++++++++++
|
||||
test/validate-workflow.test.mjs | 3 +++
|
||||
```
|
||||
|
||||
Дельта локальна и пропорциональна находке (одна правка на одну строку внутри
|
||||
уже одобренного шага): AC, которые она задевает, — только тот же самый AC про
|
||||
«одно issue, не дубликат» из тела #700 и контракта PROCESS.md §10.4. Остальной
|
||||
диапазон (`check-docs.mjs`, `PROCESS.md`, `test/classify-changes.test.mjs`,
|
||||
`test/docs-freshness.test.mjs`) не тронут этим коммитом — соответствующая
|
||||
часть r1 наследуется без повторной проверки (раздел ниже).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты подтверждены зелёным Validate на этом же SHA `ed3d63e2`
|
||||
(workflow_dispatch, run `36484635253`, `conclusion: success`, ссылка дана в
|
||||
задаче ревью) — `npx tsc --noEmit`, `npm test`, `npm run build` +
|
||||
`bundle-policy --verify` повторно не гонял.
|
||||
|
||||
Дополнительно к зелёному Validate прогнал сам:
|
||||
|
||||
| Гейт | Результат |
|
||||
|---|---|
|
||||
| `node --test test/validate-workflow.test.mjs` | 27/27 зелёных, включая изменённый `#700: на ветке задачи…` |
|
||||
| `node --test test/classify-changes.test.mjs` | 21/21 зелёных (не тронут этим коммитом, для очистки дельты) |
|
||||
| `node --test test/mutation-gate.test.mjs` | 69/69 зелёных, структура нового мутанта `workflow-sync-issue-duplicated-on-read-failure` валидна |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет» — src/**/*.ts не тронут, браузерные смоки нечего выбирать |
|
||||
| Мутация вручную из реестра: `exit 0` → `:` в новом `if`-блоке (patch `workflow-sync-issue-duplicated-on-read-failure`), тест `--test-name-pattern="#700: на ветке задачи"` | падает (1 fail, `assert.doesNotMatch` на `|| true)` и/или `assert.match` на новый `if !`-блок), файл восстановлен из бэкапа, `git status` чист |
|
||||
|
||||
Живое воспроизведение реального сбоя `gh issue list` на CI не запускал (это
|
||||
живой internal API-вызов на push в `dev`, вне доступа из этой сессии, и это
|
||||
разрушительное действие, если сорвётся не так, как задумано) — как и в r1,
|
||||
логика закрыта юнит-тестом плюс ручной мутацией guard-команды выше.
|
||||
|
||||
## Находки
|
||||
|
||||
Нет. Правка r2 точна: устраняет ровно описанный в r1 путь, не расширяет и не
|
||||
сужает ничего другого в шаге.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium: `gh issue list … \|\| true` — сбой чтения списка открытых issue проваливается в `gh issue create`, заводя дубликат `[workflow-sync]` вместо предупреждения | `\|\| true` убран; чтение обёрнуто в `if ! existing=$(gh issue list …); then echo "::warning::…"; exit 0; fi` — при ненулевом коде `gh issue list` шаг предупреждает и выходит, не доходя ни до `gh issue comment`, ни до `gh issue create` | `.github/workflows/validate.yml` (коммит `ed3d63e2`, блок шага «Расхождение зеркала на dev — issue владельцу»); поведение зафиксировано тестом `test/validate-workflow.test.mjs:628-633` (два новых `assert`: на форму `if !…then…exit 0…fi` и на отсутствие `\|\| true)` до `gh issue create`) и мутантом `workflow-sync-issue-duplicated-on-read-failure` в `scripts/mutation-registry.mjs`, который я применил вручную — тест краснеет на мутированном коде и зеленеет на исходном (таблица гейтов выше) |
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Принято без повторной проверки в этом раунде — документ и материал:
|
||||
`docs/reviews/CODE-REVIEW-700-r1.md` (в дереве `545be665`), материал
|
||||
`89d57e054cdf940d3ead83ec509075164118ee3b`, дерево материала `945dad882326…`:
|
||||
|
||||
- Периметр `task_branch=true/false` не сузился: `check`/`advise` для
|
||||
workflow_sync и `--external`/без `warn` для внешних ссылок остаются красными
|
||||
для push в `dev` и для кандидата беты/релиза; предупреждением становится
|
||||
только ветка задачи.
|
||||
- `validate.yml` не входит в список из шести тонких зеркалируемых файлов —
|
||||
этому диффу зеркалирование в `main` не требуется.
|
||||
- `check-docs.mjs`: `EXTERNAL_WARN` корректно разводит `warnings`/`errors`,
|
||||
выход по `errors.length` не тронут.
|
||||
- Трейлеры коммита r1 (`Issue: #700`, `User-Visible: no`) — корректны, диффу
|
||||
класса A/D нет.
|
||||
- `PROCESS.md` §10.4 дополнен точно тем, что реализовано в r1.
|
||||
- Оба мутанта r1 (`task-branch-workflow-sync-red-again`,
|
||||
`external-link-warn-mode-ignored`) — краснеют на мутации, зеленеют на
|
||||
исходнике (проверено в r1 вручную).
|
||||
- `actionlint` не перепроверялся (недоступен локально и в r1, и сейчас);
|
||||
косвенно — YAML реально исполнился на CI (run r1 `36482200722` и run r2
|
||||
`36484635253`) без ошибки парсинга.
|
||||
|
||||
## Что проверено и корректно (r2, сверх наследования)
|
||||
|
||||
- Новый `if`-блок синтаксически и семантически корректен для `bash -eo
|
||||
pipefail` (шелл GitHub Actions по умолчанию): присваивание внутри условия
|
||||
`if !` не триггерит `set -e`, а код возврата, который проверяется, — код
|
||||
возврата `gh issue list` (json/jq считает сам `gh`, не отдельный процесс в
|
||||
пайпе), так что сетевой сбой или рейт-лимит действительно попадает в ветку
|
||||
`if`, а не проскакивает мимо неё.
|
||||
- Ветка «не прочитано → предупреждение и выход» физически предшествует и
|
||||
`gh issue comment`, и `gh issue create` — при сбое чтения оба этих вызова
|
||||
теперь не выполняются вообще, то есть дубликат исключён, а не просто
|
||||
переименован в другую ошибку.
|
||||
- Комментарий в коде (`# Список не прочитан — …`) объясняет неочевидную
|
||||
причину (почему это не «оставить || true»), а не пересказывает код — по
|
||||
стилю совпадает с уже принятыми в r1 комментариями этого же шага.
|
||||
- Новый мутант в реестре (`workflow-sync-issue-duplicated-on-read-failure`)
|
||||
минимален и целится ровно в закрытый путь (`exit 0` → `:`, единственная
|
||||
правка, из-за которой шаг снова провалился бы в `if [ -n "$existing" ]`
|
||||
с пустым `$existing`).
|
||||
- Изменение не расширяет и не меняет скоуп задачи: файлов класса A/D нет,
|
||||
видимое поведение карточки не меняется, `docs/USER-GUIDE.ru.md` не
|
||||
требует правок.
|
||||
- Одно число, один источник (§8): числовых значений, видимых пользователю
|
||||
карточки, дифф r2 не вводит.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- `mutation-gate --check` (полный прогон, а не структура) и весь `npm run
|
||||
gate:small` — дешёвые гейты уже подтверждены зелёным Validate на этом SHA,
|
||||
трек `show` не запрашивает мутанты по диффу целиком (#696).
|
||||
- Живое исполнение шага «Расхождение зеркала на dev — issue владельцу» с
|
||||
реально упавшим `gh issue list` на push в `dev` — не воспроизводил (нужен
|
||||
реальный сбой GitHub API или подмена `gh` внутри Actions-раннера, недоступно
|
||||
из этой сессии); логика закрыта юнит-тестом плюс ручной мутацией, как в r1.
|
||||
- `golden:verify`, `pytest tests_backend`, инварианты модели, performance —
|
||||
неприменимо, дифф всего диапазона не трогает `src/**`, рендер, геометрию или
|
||||
Python (подтверждено ещё в r1, r2 диапазон это не меняет).
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка r1 закрыта точной правкой с тестом и проверенным
|
||||
мутантом; новых находок нет.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/700-preflight-warnings`, коммит `ed3d63e20db2` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `9a8052d18a3baa6cadd0907021a3a01dfc11d935`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 9a8052d18a3b
|
||||
```
|
||||
- Тело issue: `a0c40d40c1401844c3d04ff0695dd347e76172d990004ea2799cc4df38729fce`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,103 @@
|
||||
# CODE-REVIEW-700-r4
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Issue: [#700](https://github.com/Matysh/houseplan-card/issues/700), трек `track:show`, инфраструктурный маршрут (класс B: `.github/workflows/**`, `scripts/**`, `test/**`; класс C: `PROCESS.md`, `docs/reviews/**`). Файлов класса A нет.
|
||||
- SHA материала: **`1aa52d21071705574c6cccb1f19ccc3a8d5b9083`** (рабочая копия уже на нём, `git status` чист).
|
||||
- Диапазон: `git log --oneline origin/dev..HEAD` — 4 коммита:
|
||||
`e45bc87c` (задача, r0) → `a005aae5` (докс r1) → `f6e317d8` (задача, правка Medium из r1) → `1aa52d21` (докс r2).
|
||||
- `git diff origin/dev...HEAD --stat`: `validate.yml` +66/-12, `PROCESS.md` +7/-1, `scripts/check-docs.mjs` +12/-1, `scripts/mutation-registry.mjs` +34, три тестовых файла, два committed документа ревью (`CODE-REVIEW-700-r1.md`, `-r2.md`).
|
||||
- Validate на этом SHA: **success**, https://github.com/Matysh/houseplan-card/actions/runs/36492360526 — дешёвые гейты (`tsc --noEmit`, `npm test`, `npm run build` + сверка бандла) подтверждены этим прогоном, повторно не гонял.
|
||||
|
||||
## Почему разбор полный, а не по дельте (§2.10)
|
||||
|
||||
Формально с r2 (доказанно зелёный, `docs/reviews/CODE-REVIEW-700-r2.md`) код задачи не менялся ни байтом: r3 применил тот же вердикт повторно без вызова модели, потому что дерево `a6670a47` совпадало с проверенным. Но между r3 и r4 ветку **пришлось перебазировать** на ушедший на 11 коммитов вперёд `dev` (`e1700757`) — push кандидата рвался токеном конвейера без права `workflow` (#705), и автор сам пометил: «дифф против `dev` сменил контекст `validate.yml` … ожидаю новый заход ревью, а не повтор вердикта r2». Это прямо описанное в задании исключение — «ребейз на ушедший вперёд `dev`» — где разбор остаётся полным. Сделал полный разбор: перечитал итоговый `preflight` job целиком (не только хунки диффа), проверил, как правка `#700` сочетается с соседними правками `dev` (`#696` — цена захода по треку/мутанты по диффу; `#697` — режим скриншотов на ветке задачи), и заново прогнал целевые тесты и три зарегистрированных мутанта.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. Прочитан весь `.github/workflows/validate.yml` job `preflight` в его текущем виде (после ребейза), не только диф-хунки — искал конфликт правки `#700` с окружающим контекстом от `#696`/`#697`.
|
||||
2. Прочитан `scripts/check-docs.mjs` целиком вокруг флага `--external`/`--external=warn` и путь `errors`/`warnings`/`process.exitCode`.
|
||||
3. Прочитаны все три тестовых диффа (`test/validate-workflow.test.mjs`, `test/docs-freshness.test.mjs`, `test/classify-changes.test.mjs`) и правка `PROCESS.md`.
|
||||
4. Прогнаны целевые юниты: `node --test test/validate-workflow.test.mjs test/docs-freshness.test.mjs test/classify-changes.test.mjs` — 52/52 зелёных.
|
||||
5. Для каждого из трёх мутантов `#700` в `scripts/mutation-registry.mjs` (`task-branch-workflow-sync-red-again`, `workflow-sync-issue-duplicated-on-read-failure`, `external-link-warn-mode-ignored`) применил патч руками, перезапустил соответствующий `--test-name-pattern`, убедился, что тест краснеет, откатил файл (`git status` после — чист). Это не требуется на `track:show` (мутанты по диффу не запрашиваются), но дёшево и напрямую проверяет дисциплину «тест умеет падать» для тестов, которые я использую как доказательство.
|
||||
6. `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)» — прямое совпадение «нечего выбирать», не решение пропустить.
|
||||
7. Сверил трейлеры всех 4 коммитов (`git log -1 --format=%B`): `Issue: #700` на каждом; `User-Visible: no` на задачных — корректно, изменение не видно продукту; на докс-коммитах трейлеры избыточны, но не вредят.
|
||||
8. Сверил, что лейблы `infra`/`process`, использованные в новом шаге создания issue, существуют в репозитории (`gh label list`).
|
||||
|
||||
## Гейты: что прогнал, что нет, почему
|
||||
|
||||
| Гейт | Статус | Почему |
|
||||
|---|---|---|
|
||||
| `npx tsc --noEmit`, `npm test`, `npm run build` + сверка 3 копий бандла | не гонял повторно | зелёный Validate на этом же SHA (run 36492360526) уже подтвердил — цена та же, повторный прогон ничего нового не даст (#343) |
|
||||
| `node --test test/validate-workflow.test.mjs test/docs-freshness.test.mjs test/classify-changes.test.mjs` | прогнал сам | целевые тесты по диффу; 52/52 |
|
||||
| 3 мутанта `#700` из `mutation-registry.mjs` вручную | прогнал сам | не требуется на `show`, но дёшево и это единственное прямое доказательство «тест краснеет» на защитных AC |
|
||||
| `actionlint validate.yml` | не гонял (инструмент не установлен в среде) | синтаксис YAML фактически подтверждён тем, что Validate на этом SHA успешно исполнил job — невалидный YAML GitHub Actions не запустил бы вовсе |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прогнал | требование прогона по диффу; результат — «нечего выбирать» (нет `src/**`) |
|
||||
| `npm run golden:verify` | не гонял | нет метки `ci:golden`, golden-файлы не тронуты |
|
||||
| `python -m pytest tests_backend -q` | не гонял | `custom_components/**/*.py` не тронут |
|
||||
| `npm run invariants -- --config <export>` | не гонял | геометрия и ссылки на неё не тронуты |
|
||||
| performance-профили | не гонял | не названы в AC |
|
||||
| Поведение на push в `main` / тег релиза (не `dev`, не `issue/*`) | проверено чтением, не исполнением | в среде ревью нет доступа поднять реальный push-событие на `main`; `case "$REF" in refs/heads/issue/*)` — единственная новая ветка условия, любой другой `ref` (включая `main`/теги) идёт по прежнему строгому пути `check()`/`--external` без изменений в этой ветке кода |
|
||||
|
||||
## AC (тело issue, раздел «Предложение», 3 пункта)
|
||||
|
||||
| AC | Чем доказан | Чем краснеет |
|
||||
|---|---|---|
|
||||
| 1. На `issue/*` расхождение зеркала и упавшая внешняя ссылка — предупреждение в сводку, без красного job | Автотест `test/validate-workflow.test.mjs` («#700: на ветке задачи…») читает `advise()`/`check()`-ветвление и `--external=warn`; я применил мутант `task-branch-workflow-sync-red-again` (`advise`→`check`) — тест упал; применил `external-link-warn-mode-ignored` — тест упал | Оба мутанта воспроизведены вручную и убиты; откат подтверждён `git status` |
|
||||
| 2. Блокируют только push в `dev`, кандидат беты, релиз | Прочитано по коду: `case "$REF" in refs/heads/issue/*)` — единственное исключение; `check-docs.mjs` то же самое условие в `validate.yml`. Кандидат беты — коммит с `Release:` на `dev` (тот же `ref`, тот же строгий путь), не отдельная ветка | Проверено чтением, не исполнением (нет доступа поднять push-событие на `main`/тег в среде ревью) |
|
||||
| 3. Расхождение зеркала на `dev` заводит одно issue, если такого ещё нет (как ночной `#472`) | Шаг «Расхождение зеркала на dev — issue владельцу», `if: push && ref==dev && workflow_sync.outcome=='failure'`; мутант `workflow-sync-issue-duplicated-on-read-failure` (снимает фикс r1: `exit 0`→`:`) убит — тест `#700: на ветке задачи…` красный на мутанте, зелёный на исходнике | Мутант воспроизведён вручную и убит; логика идентична принятому прецеденту `_mutation-gate.yml:317-333` (одно issue, комментарий к открытому) |
|
||||
|
||||
Все три AC — track `show`, до трёх штук в теле issue, лимит соблюдён.
|
||||
|
||||
## Рассмотрено и отклонено как находка
|
||||
|
||||
**Гонка/ложное срабатывание при сбое `git fetch` внутри шага `workflow_sync`.** Шаг начинается с `git fetch --quiet origin main dev`; в GitHub Actions шаги `run:` по умолчанию исполняются с `bash -eo pipefail`, поэтому сбой сети на этой строке уронит шаг **до** цикла `diff`, и `steps.workflow_sync.outcome` станет `failure` без реального расхождения зеркала. После `#700` это не просто красит один прогон (как было раньше) — на push в `dev` это теперь автоматически заводит владельцу постоянный GitHub issue `[workflow-sync]`, который придётся закрывать вручную, хотя расхождения нет.
|
||||
|
||||
Не поднимаю как Medium: это тот же дизайн, который уже принят и явно задокументирован в прецеденте, на который ссылается сама задача — `_mutation-gate.yml:275` открытым текстом говорит «любой другой пропуск шардов (упал `material`) по-прежнему заводит issue», то есть владелец уже согласился не различать «содержательный отказ» и «инфраструктурный сбой» ради простоты. Задача `#700` не меняет и не ухудшает это поведение — она копирует уже принятый паттерн один в один. Расширять скоуп до различения причин отказа шага — не работа этой задачи.
|
||||
|
||||
**Гонка двух параллельных push в `dev`, оба находят issue не открытым и оба создают дубликат.** Технически возможно между `gh issue list` и `gh issue create` в двух разных прогонах, но `concurrency: group: validate-...${{ github.ref }}` с `cancel-in-progress: true` отменяет предыдущий прогон на том же `ref` при новом push — два прогона `preflight` для `dev` одновременно не живут в штатном случае. Не нахожу воспроизводимого сценария в рамках обычного использования; Low, не блокирует.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- `advise()` не устанавливает `fail=1` — предупреждение не красит итоговый вердикт job (проверено чтением и мутантом).
|
||||
- `check-docs.mjs`: при `--external=warn` отказы внешних ссылок уходят в `warnings`, а не в `errors`, значит `process.exitCode` не выставляется этой причиной — шаг `docs` не падает от чужого сайта на ветке задачи. Остальные проверки документации (пропущенный alt, отсутствующий якорь, скриншот-манифест и т. д.) остаются в `errors` независимо от ветки — AC не про них, и они по-прежнему красят.
|
||||
- `permissions: issues: write` добавлено на уровне job, а не workflow — оценил риск: `validate.yml` **не входит** в список из шести «тонких» файлов, зеркалируемых в `main` (`process.yml`, `mutation-gate.yml`, `process-resume.yml`, `nightly.yml`, `process-reconcile.yml`, `process-metrics.yml`), значит новое право не нужно синхронизировать в `main` — согласуется с тем, что сам список этот файл не включает.
|
||||
- Фикс r1 (`gh issue list … || true` → `if ! existing=$(…); then …; exit 0; fi`) закрыт и не регрессировал при ребейзе: воспроизведён мутант, тест падает.
|
||||
- Трейлеры `Issue:`/`User-Visible:` на месте на обоих коммитах класса B; `User-Visible: no` корректен — изменение невидимо продукту, оба changelog не требуются.
|
||||
- Одно число — один источник (§8): в диффе нет пользовательски видимых чисел (внутренний CI-процесс), пункт неприменим.
|
||||
- Лейблы `infra`, `process`, используемые в `gh issue create`, существуют в репозитории.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- `actionlint` сам не гонял (инструмент недоступен в среде ревью) — полагаюсь на успешный прогон Validate на этом SHA как косвенное доказательство валидности YAML.
|
||||
- Реальное поведение на push в `main` и на релизном теге не воспроизводил исполнением — только чтением кода (нет средства поднять такое событие в среде ревью); риск минимален, так как единственная новая ветка условия — `issue/*`, остальное не тронуто.
|
||||
- Полные наборы (golden, HA pytest, инварианты, performance) не гонял — ни один AC их не требует, дифф их не касается.
|
||||
- Мутанты по диффу в `mutation-gate.yml`-смысле («на этом SHA») не запрашивал — не требуется на `track:show`; три мутанта из реестра, которые сам автор завёл под `#700`, проверил вручную вместо этого (не обязательное, но дешёвое усиление).
|
||||
|
||||
## Закрытие раунда r3
|
||||
|
||||
r3 не вносил новых находок: он применил зелёный вердикт r2 повторно без вызова модели, потому что дерево `a6670a47` было побайтово тем же, что проверялось в r2. Закрывать нечего.
|
||||
|
||||
## Унаследовано из r3 / переподтверждено в r4
|
||||
|
||||
Код задачи (`validate.yml`, `check-docs.mjs`, три тестовых файла, `mutation-registry.mjs`, `PROCESS.md`) текстуально идентичен тому, что получило зелёный вердикт в `docs/reviews/CODE-REVIEW-700-r2.md` (SHA материала r2 — `ed3d63e2`/`a6670a47`). Несмотря на это, **не переносил вердикт по правилу «дерево не изменилось»**, а провёл полный разбор заново: рабочая копия ребейзнута на 11 коммитов вперёд `dev` (§2.10, «ребейз на ушедший вперёд `dev`» — явное исключение из сокращённого объёма), и сам автор указал, что итоговый контекст `validate.yml` внутри `preflight` сменился из-за параллельных правок `#696`/`#697`. Результат независимого полного разбора совпал с r2: 0 High, 0 Medium.
|
||||
|
||||
## Итог
|
||||
|
||||
High: 0. Medium (в скоупе): 0. Medium (вне скоупа): 0. Low: 1 (гонка двух параллельных push в `dev`, отклонена как невоспроизводимая при текущем `concurrency`, не правится и не заводится отдельно). Все три AC доказаны — два исполнением с падающим мутантом, один чтением с явной пометкой «проверено чтением, не исполнением» из-за ограничений среды ревью, а не из-за отсутствия автотеста.
|
||||
|
||||
**Вердикт: зелёный.**
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/700-preflight-warnings`, коммит `1aa52d210717` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `72952e142f2868e6a1cf473135996b79e5eacb5e`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 72952e142f28
|
||||
```
|
||||
- Тело issue: `a0c40d40c1401844c3d04ff0695dd347e76172d990004ea2799cc4df38729fce`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,138 @@
|
||||
# CODE-REVIEW-701-r1
|
||||
|
||||
- Issue: #701 «Канон: 26 расхождений документов между собой и с кодом, дубли правил, уроки одного случая»
|
||||
- Трек: `show` · заход r1 · блокирующих циклов израсходовано 0 из 2
|
||||
- Материал: `git log --oneline origin/dev..HEAD` → один коммит `8dcc1cad4edeed369f751d6b9fe96b798b4061e4`
|
||||
поверх `origin/dev` @ `224d0106`; `git diff origin/dev...HEAD` — 21 файл,
|
||||
232 вставки / 111 удалений
|
||||
- Класс изменений: B (scripts, tests, `.github/workflows/_process.yml`) + C
|
||||
(docs) — нет файлов класса A; задача инфраструктурная (PROCESS.md §1),
|
||||
ТЗ не пишет — соответствует правилу
|
||||
- Validate на этом SHA зелёный: https://github.com/Matysh/houseplan-card/actions/runs/36486646199
|
||||
|
||||
## Скоуп
|
||||
|
||||
Issue фиксирует 26 расхождений (D1–D26) между `PROCESS.md`, ролевыми
|
||||
конспектами (`AUTHOR.md`, `REVIEWER.md`), `AGENTS.md`, `TESTING.md`,
|
||||
`CONTRIBUTING.md`, `docs/DEVELOPMENT.md`, `docs/STATUS.md`, `docs/LESSONS.md`,
|
||||
`docs/specs/README.md`, промптом `_process.yml` и исполняемым кодом
|
||||
(`process-gate.mjs`, `validate-commit-provenance.mjs`, `mutation-registry.mjs`,
|
||||
`entry-cost.mjs`, `gate-small.mjs`). Автор решает каждое расхождение «в пользу
|
||||
кода, если код — действующее решение владельца, иначе чинится код» и добавляет
|
||||
одно материальное поведенческое изменение: **документационный коммит (только
|
||||
файлы класса C) больше не требует трейлеров `Issue:`/`User-Visible:`** — вынесен
|
||||
общий модуль `scripts/change-classes.mjs`.
|
||||
|
||||
Работа обслуживает J6 «Keep the plan true as the home evolves» лишь косвенно —
|
||||
это сам процесс поддержки продукта в согласованном состоянии, разрешено §1
|
||||
AGENTS.md как «infrastructure» и подтверждено меткой `process` + `docs`.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Прочитан весь `git diff origin/dev...HEAD` (все 21 файл, все 15 hunks в
|
||||
`PROCESS.md`), сопоставлен построчно с таблицей решений D1–D26 из комментария
|
||||
автора. Ниже — таблица только по пунктам, где проверка потребовала выйти за
|
||||
простое чтение диффа (код + тест + мутант); остальные D-пункты — точечные
|
||||
правки текста, сверенные визуально построчно с диффом и не приведённые
|
||||
отдельной строкой, чтобы не раздувать документ.
|
||||
|
||||
| D | Что проверено | Результат |
|
||||
|---|---|---|
|
||||
| D1 | `scripts/change-classes.mjs` — новый общий модуль; `validate-commit-provenance.mjs` импортирует `classify`, добавляет `isDocsOnlyCommit`; `process-gate.mjs` импортирует `classify` вместо локальной копии | код идентичен по регэкспам исходной копии из `process-gate.mjs` (diff — чистый вырез/вставка), порядок D→A→B→C сохранён |
|
||||
| D1 (тест) | `isDocsOnlyCommit` доказана | `node --test test/commit-provenance.test.mjs` — зелёный; вручную применены оба мутанта реестра (`docs-only-commit-needs-trailers-again`, `docs-only-exemption-leaks-to-code`) — тест краснеет на обоих, рабочая копия восстановлена (`git status --porcelain` пуст) |
|
||||
| D1 (хук) | `.githooks/commit-msg` зовёт `--staged`, значит `changedFiles` — реальный staged-список, а не пустой список из документного примера | прочитан `.githooks/commit-msg:13-14` |
|
||||
| D1 (реестр) | `legacy/**` классифицируется как C с комментарием «только Markdown» | `find legacy -type f \| grep -v '\.md$'` → 0 файлов, подтверждено |
|
||||
| D2 | `REVIEW_DOC_LIMIT = 6` в `process-gate.mjs` не менялся, только текст PROCESS.md подгонян под код | `grep -n REVIEW_DOC_LIMIT scripts/process-gate.mjs` совпадает с новой формулировкой «шесть документов одного вида» |
|
||||
| D14 | Порядок чтения ревьюера в `_process.yml` (SCOPE→AGENTS→REVIEWER) совпадает с `ROUTES.reviewer.files` в `entry-cost.mjs` | тест `test/entry-cost.test.mjs` «#701 D14» зелёный; `node scripts/entry-cost.mjs` — совпадение подтверждено |
|
||||
| Слова входа | Автор заявляет 5345→4703 (в тексте issue) / 5171→4703 (комментарий); коммит называет 5345→4703 | `node scripts/entry-cost.mjs` на HEAD даёт 4706 слов (расхождение в 3 слова — рероллы markdown/пробелы, не расхождение по существу) |
|
||||
| Гейты | Юниты, задетые диффом | `node --test test/commit-provenance.test.mjs test/entry-cost.test.mjs test/process-digests.test.mjs test/pre-push-gate.test.mjs` — все зелёные (19+13+1+12 тестов) |
|
||||
| Смоки | Есть ли исполняемый frontend-дифф | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → «Исполняемого frontend-диффа нет … Browser-smoke этим диффом не выбираются — выбирать нечего» — прямой и однозначный ответ инструмента, ни один смок не пропущен |
|
||||
| Трейлеры | `Issue:`/`User-Visible:` коммита | `git show -s --format=full HEAD` — `Issue: #701`, `User-Visible: no`; коммит сам содержит файлы класса B (scripts/tests/workflow), поэтому по новому правилу трейлеры обязательны и присутствуют — код применён к себе корректно |
|
||||
|
||||
## Что не проверял (и почему это ничего не пропускает)
|
||||
|
||||
- `npx tsc --noEmit`, `npm test` (полный), `npm run build` + сверка бандла —
|
||||
не перегонял: Validate на этом же SHA `8dcc1cad` зелёный (ссылка выше),
|
||||
бюджет раунда потрачен на чтение кода, как разрешает промпт ревью.
|
||||
- `npm run golden:verify` — не запускался: метки `ci:golden` на issue нет,
|
||||
дифф не касается `demo/golden/**` и рендера.
|
||||
- `python -m pytest tests_backend -q` — не запускался: дифф не касается
|
||||
`custom_components/**/*.py`.
|
||||
- `npm run invariants -- --config <export>` — не запускался: дифф не меняет
|
||||
геометрию модели ни ссылки на неё.
|
||||
- Performance-профили — не запускались: не названы в AC/issue.
|
||||
- Полный `npm run inventory` / полный прогон `mutation-gate.yml` (все шесты
|
||||
шардов) — не запускался; автор заявляет локальный прогон диф-мутантов
|
||||
«184 из 184» на `show`, где диф-мутанты не обязательны (REVIEWER.md, «Трек
|
||||
show»): проверены точечно только два новых мутанта, относящихся к диффу
|
||||
(см. таблицу выше), остальные 182 не переисполнялись — трек `show` этого не
|
||||
требует.
|
||||
- Не перечитывал все 26 точечных текстовых правок построчно вслух в этом
|
||||
документе — свёл к таблице выше плюс визуальному сопоставлению с полным
|
||||
диффом (`git diff origin/dev...HEAD -- PROCESS.md` и остальные файлы,
|
||||
прочитаны целиком инструментом чтения, не по фрагментам).
|
||||
|
||||
## Находки
|
||||
|
||||
Нет находок уровня High или Medium. Ниже — один пункт уровня Low, снятый без
|
||||
правки.
|
||||
|
||||
- **Low, снят.** Число слов входа автора в коммите/issue (5345/5171→4703) и на
|
||||
измеренном HEAD (4706) расходятся на 2–3 слова. Причина, скорее всего, —
|
||||
разница момента замера (до/после финальной правки одного предложения) или
|
||||
особенность подсчёта `wc -w`-подобной функции на переносах строк; описанный
|
||||
порядок величины (экономия ~640–700 слов, цель 3000 не достигнута, остаток
|
||||
назван) не меняется, инструмент (`node scripts/entry-cost.mjs`) сам
|
||||
показывает актуальное число при каждом запуске, а не текст документа —
|
||||
учебная находка, не поведенческая. Снимаю без возврата автору.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Общий модуль классов (`change-classes.mjs`) устраняет дублирование логики
|
||||
между `process-gate.mjs` и `validate-commit-provenance.mjs`, поведение не
|
||||
изменилось для не-docs-only коммитов (тесты на классах A/B/D в
|
||||
`test/commit-provenance.test.mjs` не тронуты и зелёные).
|
||||
- Новое освобождение docs-only коммитов от трейлеров реализовано корректно:
|
||||
пустой список файлов не считается docs-only («судить нечем»), любой файл вне
|
||||
класса C возвращает коммит под старое правило, указанный трейлер всё равно
|
||||
проверяется на валидность (тест `docs: typo\n\nIssue: #x` всё ещё краснеет).
|
||||
Оба защитных инварианта доказаны мутантами и я лично воспроизвёл оба падения.
|
||||
- Изменение маршрута автора (`docs/STATUS.md` убран из обязательного входа)
|
||||
синхронно отражено в `entry-cost.mjs`, `AGENTS.md` и тексте PROCESS.md —
|
||||
один источник, три места ссылаются, а не повторяют число.
|
||||
- Переупорядочение маршрута ревьюера в промпте `_process.yml` (SCOPE→AGENTS→
|
||||
REVIEWER) сопровождается новым тестом, который сверяет промпт и
|
||||
`entry-cost.mjs` исполнением, а не на слово доверия.
|
||||
- Автор честно перечислил недоделанное (п.4 «уроки → чек-листы подсистем» и
|
||||
остаток до 3000 слов) как отдельное issue-предложение, а не спрятал в тексте.
|
||||
- Одно число, видимое дважды в этом диффе (порог `REVIEW_DOC_LIMIT`), имеет
|
||||
один источник — константу в `process-gate.mjs`; текст PROCESS.md её называет,
|
||||
не дублирует.
|
||||
- Риск, названный самим автором (файл, ошибочно отнесённый к классу C,
|
||||
пройдёт без трейлеров), — не находка ревью: он не увеличился этим диффом
|
||||
(тот же риск существовал и раньше в неявном виде, для `process-gate.mjs`),
|
||||
а сведение классов в один модуль его уменьшает, а не создаёт.
|
||||
|
||||
## Раздел «Унаследовано» — не применяется
|
||||
|
||||
Это первый заход (r1) по этому issue; предыдущего раунда и материала нет.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. AC (D1–D26 плюс материальное изменение трейлеров) доказаны кодом,
|
||||
тестами и исполнением мутантов там, где они заявлены; гейты, применимые к
|
||||
этому диффу, зелёные или обоснованно не запускались; High/Medium находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/701-canon-consistency`, коммит `8dcc1cad4ede` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `18f1bb93547b43c41d19aecdcecb67e0fe35f3c2`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 18f1bb93547b
|
||||
```
|
||||
- Тело issue: `592acae7c08ffffa64b312987ca7cff1e28155412a2c6531cb3e4eab0877eedf`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,197 @@
|
||||
# CODE-REVIEW-702-r1
|
||||
|
||||
Issue: #702 · Трек: show · Заход: r1 · Блокирующих циклов израсходовано: 0 из 2
|
||||
Материал ревью: `3a681e24e49038f202fc74cc3f5482e93e43977e` (ветка `issue/702-delete-merged-branches`, один коммит поверх `dev@c716bb0f`)
|
||||
|
||||
## Скоуп
|
||||
|
||||
Задача — гигиена веток конвейера (issue #702): 368 влитых `issue/*` веток
|
||||
висели на `origin`, список веток переставал что-то значить, агент, искавший
|
||||
ветку по номеру, мог взять устаревшую. Предложение из тела issue:
|
||||
|
||||
1. После успешного слияния `merge-candidate.mjs` удаляет ветку задачи;
|
||||
удаление — только если вершина ветки равна влитому кандидату.
|
||||
2. Разовая чистка уже влитых веток — список владельцу, удаление только после
|
||||
явного согласия.
|
||||
3. Не трогать `codex/*`, релизные и невлитые ветки.
|
||||
|
||||
Диапазон правки (`git diff origin/dev...HEAD`):
|
||||
|
||||
| Файл | Класс | Что |
|
||||
|---|---|---|
|
||||
| `scripts/merge-candidate.mjs` | B (tooling) | `deleteBranch()` в `realOps`; вызов из `finish()` с `branchTip`, проброшенным из обеих веток слияния (fast-forward и push после Validate); текст комментария в `commentFor()` |
|
||||
| `PROCESS.md` §10.4 | C (docs) | пункт в списке шагов слияния |
|
||||
| `test/merge-candidate.test.mjs` | B | 4 новых теста (`fakeOps` получил `deleteBranch`/`deleteOk`) |
|
||||
| `scripts/mutation-registry.mjs` | B | мутант `merged-task-branch-kept` |
|
||||
|
||||
Файлов класса A нет — продуктовый код карточки не тронут, отсюда
|
||||
`User-Visible: no` в трейлере коммита (корректно). Пункт 2 (разовая чистка)
|
||||
в диффе не реализован кодом — по комментарию автора список подготовлен
|
||||
(`branches-to-delete-702.txt`), удаление не выполнялось, ждёт согласия
|
||||
владельца. Это соответствует AC: чистка — ручное действие владельца, не
|
||||
автоматизация этой задачи.
|
||||
|
||||
Ветка к `dev` не приводилась (трек show, #696): сливается с открытыми
|
||||
задачами #697–#700 без конфликта (`git merge-tree`, со слов автора; не
|
||||
перепроверялось — не относится к AC этой задачи).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Ревью читало код и намеренно воспроизвело граничный случай, который юнит-тест
|
||||
подменяет фейком: сам механизм `git push --force-with-lease` на удаление ветки.
|
||||
|
||||
- Прочитан `scripts/merge-candidate.mjs` целиком: путь `finish()`, оба места,
|
||||
где `branchTip` попадает в `extra` (строка ~287 — fast-forward, ~332 —
|
||||
после зелёного Validate), и путь `commentFor()`.
|
||||
- Прочитан весь `test/merge-candidate.test.mjs`: `fakeOps`, все четыре новых
|
||||
теста и их согласованность с `mergeCandidate()`.
|
||||
- Выполнен независимый эксперимент на временном bare-репозитории (два
|
||||
прогона): `git push --force-with-lease=refs/heads/X:<ожидание> origin
|
||||
:refs/heads/X` — (а) при совпадении ожидания с реальной вершиной ветка
|
||||
удаляется (`exit 0`); (б) при устаревшем ожидании push отклоняется с
|
||||
`! [rejected] (delete) -> feature (stale info)`, ветка остаётся. Это
|
||||
подтверждает, что регэксп `/stale info|rejected|fetch first|lease/i` в
|
||||
`deleteBranch()` ловит именно ту ошибку, которую реально отдаёт git, а не
|
||||
придуманную автором строку.
|
||||
- Прогнаны узкие автотесты и гейты, относящиеся к диффу (список — в таблице
|
||||
«Гейты» ниже).
|
||||
- Проверены трейлеры коммита, сверка чисел (нет числа, видимого дважды —
|
||||
единственное число диффа, SHA/статусы, генерируется кодом, не
|
||||
дублируется руками).
|
||||
- Проверено, что job `integrate` в `_process.yml`, вызывающая
|
||||
`merge-candidate.mjs`, использует не GITHUB_TOKEN (у которого в этом job
|
||||
только `contents: read`), а секрет `HP_PROCESS_TOKEN` через явный URL с
|
||||
токеном — то есть право на push/delete не зависит от `permissions:` блока
|
||||
job и не сужено этой задачей.
|
||||
- Проверено, что ни один другой скрипт (`task-packet.mjs`,
|
||||
`process-gate.mjs`, `process-metrics.mjs`, `wait-verdict.mjs`) не
|
||||
предполагает существование ветки задачи после `S8-merged` — удаление
|
||||
ничего не ломает по цепочке.
|
||||
|
||||
## AC · чем доказан · чем краснеет
|
||||
|
||||
| AC | Чем доказан | Чем краснеет |
|
||||
|---|---|---|
|
||||
| Ветка удаляется после успешного слияния (fast-forward и push) | `test/merge-candidate.test.mjs` (2 теста), исполнено (26/26) | Мутант `merged-task-branch-kept` (`if (false && merged && …)`) — гард `node --test --test-name-pattern="#702" test/merge-candidate.test.mjs`; прогнан лично: `node scripts/mutation-gate.mjs --id=merged-task-branch-kept` → «merged-task-branch-kept: заявленный тест покраснел на мутанте», «поймано 1 из 1» |
|
||||
| Удаление — только если вершина ветки на origin равна вершине, которую видело слияние (lease) | Юнит-тест «сдвинутая вершина — ветка остаётся» (`deleteOk: false`); плюс независимый ручной эксперимент на настоящем git (см. «Как проверялось») — подтверждает, что `--force-with-lease` на удаление действительно защищает от гонки, а не только в фейке | Ручной эксперимент — стерев `--force-with-lease` до простого `git push origin :refs/heads/X`, оба ручных прогона поменяли бы поведение (случай (б) удалил бы ветку вместо отказа); отдельного мутанта на этот случай нет, но реальный git-эксперимент закрывает то же самое доказательство подлинным исполнением, а не фейком |
|
||||
| Красный/незавершённый merge не удаляет ветку (red Validate, conflict, reject-stale, rereview, give-up) | Юнит-тест: красный Validate → `calls.filter(delete) == []`; устаревшая ветка (#312) → `calls.filter(delete) == []` | Проверено чтением: во всех этих исходах `finish()` вызывается без `branchTip` в `extra`, а `merged` вычисляется из `decision.action`, который в этих ветках никогда не `push`/`fast-forward` — структурно недостижимо, что делает отдельный мутант избыточным для этой части |
|
||||
| Разовая чистка 368 влитых веток — не автоматически, только после согласия владельца | Чтение диффа: код чистки в этом коммите отсутствует; issue-комментарий автора подтверждает, что ничего не удалено | Не мутант — это отсутствие функциональности, доказывается отсутствием кода, а не тестом |
|
||||
|
||||
## Находки
|
||||
|
||||
Нет High. Нет Medium в скоупе. Нет Medium вне скоупа.
|
||||
|
||||
**Low (снята с записью, не блокирует):** если `deleteBranch()` бросает
|
||||
исключение, НЕ совпадающее с lease-регэкспом (например, реальная ошибка прав
|
||||
или блокировка ветки правилом защиты) — оно ловится в `finish()`
|
||||
(`catch (error) { ops.log(...) }`), и `branchDeleted` остаётся `null`.
|
||||
Комментарий в issue в этом случае вообще не упоминает попытку удаления —
|
||||
ни «удалена», ни «оставлена: вершина сдвинулась». Различить в
|
||||
issue-комментарии «ветка осталась, потому что автор допушил коммит»
|
||||
(ожидаемо, задокументировано) от «ветка осталась из-за сбоя git/прав»
|
||||
(неожиданно) можно только по логу job в Actions, который не хранится
|
||||
вечно и не виден из issue. Не блокирует: инвариант «сбой удаления не
|
||||
отменяет слияние» соблюдён, риска для данных нет — в худшем случае
|
||||
лишняя ветка молча остаётся висеть, то есть today's статус-кво. Снимаю
|
||||
без правки: цена читаемости лога ниже цены нового условного пути в
|
||||
и так плотной функции `finish()`.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- `deleteBranch(ref, expected)` в `realOps` использует тот же
|
||||
`pushUrl` (с токеном), что и `pushWithLease`, и тот же паттерн
|
||||
`--force-with-lease=refs/heads/${ref}:${expected}` — синтаксис реально
|
||||
поддерживается git для удаления (подтверждено экспериментом), не
|
||||
придуман.
|
||||
- Оба места вызова `finish(..., { branchTip })` передают корректную
|
||||
«последнюю вершину, которую видело слияние»: `tip` (= `actual`) в
|
||||
fast-forward-пути, где ветка задачи вообще не пушится, и `candidate`
|
||||
(уже реально запушенный в `branch` строкой раньше, `pushWithLease(candidate,
|
||||
branch, tip)`) в пути с ребейзом — совпадает с ожиданием юнит-тестов
|
||||
(`cand-mat-on-dev1` и т. п.).
|
||||
- Все исходы, где `merged !== true` (`conflict`, `rereview`,
|
||||
`validation-red`, `validation-missing`, `give-up`, `reject-stale`),
|
||||
структурно не передают `branchTip` и не вызывают удаление — проверено и
|
||||
чтением, и тестами.
|
||||
- `commentFor()` добавляет к тексту «слито» одну из двух опциональных
|
||||
фраз строго по `ctx.branchDeleted === true/false`; при `null` — исходный
|
||||
текст без изменений, обратной совместимости с существующими тестами
|
||||
комментариев не сломано (`assert.match(commentFor('push', ctx), /… слито$/)`
|
||||
по-прежнему проходит, т.к. `ctx.branchDeleted` не задан → `undefined`).
|
||||
- Мутант `merged-task-branch-kept` синтаксически корректен (`find` совпадает
|
||||
один-в-один со строкой файла), гард называет реальный тест, реестр цел
|
||||
(`node --test test/mutation-gate.test.mjs` — 69/69, структурные проверки
|
||||
реестра проходят).
|
||||
- Трейлеры коммита: `Issue: #702`, `User-Visible: no` — верно, продуктовый
|
||||
код (класс A) не менялся.
|
||||
- PROCESS.md §10.4 обновлён текстом, согласованным с кодом (обе ветки:
|
||||
«после push» покрывает и fast-forward, и обычный push).
|
||||
- Ни один другой скрипт конвейера не читает ветку задачи после
|
||||
`S8-merged` — удаление ничего дальше по цепочке не ломает.
|
||||
- Число, которое дифф делает видимым (368/370 влитых веток), — не
|
||||
дублируется в коде: это только текст комментария issue автора, не число
|
||||
в PROCESS.md или тесте; в самом диффе такого числа нет вовсе — нечему
|
||||
расходиться.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- `npx tsc --noEmit`, `npm test` (полный), `npm run build` +
|
||||
сверка трёх копий бандла — не перегонял: зелёный Validate на этом же SHA
|
||||
(`3a681e24`) уже подтверждён:
|
||||
https://github.com/Matysh/houseplan-card/actions/runs/36482861441
|
||||
- Браузерные смоки — не прогонял: `node scripts/smoke-select.mjs --base
|
||||
origin/dev --head HEAD` сам ответил «исполняемого frontend-диффа нет
|
||||
(`src/**/*.ts` не тронут) — смоки этим диффом не выбираются, выбирать
|
||||
нечего». Явных смоков в AC issue не названо.
|
||||
- `npm run golden:verify` — не прогонял, нет изменений рендера/UI.
|
||||
- `python -m pytest tests_backend -q` — не прогонял, нет правок
|
||||
`custom_components/**/*.py`.
|
||||
- `npm run invariants` — не прогонял, диффа геометрии/модели нет.
|
||||
- Performance-профили — не названы в AC, не прогонял.
|
||||
- Реальное поведение `merge-candidate.mjs` целиком на живом GitHub Actions
|
||||
(реальный `gh`/`GH_TOKEN`, реальные права `HP_PROCESS_TOKEN` на удаление
|
||||
ветки в этом конкретном репозитории) — не проверял; это уже
|
||||
ответственность реального прогона `integrate` при следующем слиянии, не
|
||||
предмет ревью кода. Логика вокруг него (аргументы, условия, регэксп
|
||||
ошибки) проверена чтением и одним независимым git-экспериментом на
|
||||
временном репозитории (см. «Как проверялось»).
|
||||
- Разовая чистка 368 веток (пункт 2 issue) — не в этом коммите, поэтому не
|
||||
предмет код-ревью; когда владелец даст согласие и появится код/действие,
|
||||
это отдельный материал.
|
||||
|
||||
## Гейты — сводка
|
||||
|
||||
| Гейт | Прогнан | Результат |
|
||||
|---|---|---|
|
||||
| typecheck / npm test / build+bundle-policy | нет — Validate на SHA материала | зелёный: run 36482861441 |
|
||||
| `node --test test/merge-candidate.test.mjs` | да | 26/26 |
|
||||
| `node --test test/process-digests.test.mjs` | да | 5/5 |
|
||||
| `node --test test/mutation-gate.test.mjs` | да (целостность реестра) | 69/69 |
|
||||
| `node scripts/mutation-gate.mjs --id=merged-task-branch-kept` | да | мутант убит, 1/1 |
|
||||
| `node scripts/process-gate.mjs --range origin/dev..HEAD` | да | 0 предупреждений |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | да | нет frontend-диффа, смоки не выбираются |
|
||||
| `golden:verify` | нет | не применимо (нет диффа рендера) |
|
||||
| `pytest tests_backend` | нет | не применимо (нет правок Python) |
|
||||
| `invariants` | нет | не применимо (нет правок геометрии) |
|
||||
| ручной git-эксперимент force-with-lease на удаление ref | да | подтверждает регэксп ошибки в `deleteBranch()` соответствует реальному git |
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. AC выполнены и доказаны (автотестом + мутантом + независимым
|
||||
ручным экспериментом с реальным git на защитную часть); находок,
|
||||
блокирующих или требующих правки в скоупе, нет; единственная Low-находка
|
||||
снята с записью.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/702-delete-merged-branches`, коммит `3a681e24e490` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `387871be0d5bb95001506344a933150fb5ca5d16`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 387871be0d5b
|
||||
```
|
||||
- Тело issue: `34ce91f776aff8956b77aea955e69a919ee4f52be89c8e94beb90f1e9beb5e85`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,138 @@
|
||||
# Код-ревью #706 — заход r1
|
||||
|
||||
Материал ревью: `origin/dev...HEAD`, вершина `ae0e516af16e2ebcea9642949135cb950ce65eb3`
|
||||
(один коммит поверх `dev@e7fca7b9`). Трек `show`, мутанты по диффу на материале не
|
||||
запрашивались (не находка).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Конвейер перестаёт снимать `S7-code-review` без возврата, когда исход `rereview`
|
||||
после точного кандидата (#492) возвращает задачу в ту же метку, из которой она
|
||||
пришла: `FROM == TO`. Совмещённый вызов `gh issue edit --add-label X --remove-label X`
|
||||
добавлял и тут же снимал одну метку — итог был «без статуса», событие `labeled` не
|
||||
приходило, новый заход ревью не стартовал (см. инцидент #699, зафиксированный в теле
|
||||
issue).
|
||||
|
||||
Правка вводит `scripts/status-label.mjs` (`moveStatusLabel`): при `FROM == TO` метка
|
||||
снимается и ставится заново двумя вызовами `gh` через `relabel` автосверки (#555,
|
||||
`scripts/process-reconcile.mjs:342`) — одна попытка восстановления, затем исключение;
|
||||
при разных метках — как раньше, одной правкой. Шаг «Переставить метку» в
|
||||
`_process.yml` вызывает скрипт вместо инлайн `gh issue edit`. `PROCESS.md` описывает
|
||||
новое поведение точного кандидата. Три AC из тела issue закрыты тестами
|
||||
`test/status-label.test.mjs` и двумя мутантами в `scripts/mutation-registry.mjs`.
|
||||
|
||||
Класса A (публичный API/контракт) в диффе нет — только скрипты процесса и workflow.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Дешёвые гейты на этом SHA уже подтверждены (#343): Validate `ae0e516a` — success
|
||||
(https://github.com/Matysh/houseplan-card/actions/runs/36521621591). `tsc --noEmit`,
|
||||
`npm test`, `npm run build` на этом прогоне не перегонял; вместо этого:
|
||||
|
||||
- прочитан код `scripts/status-label.mjs`, `.github/workflows/_process.yml` (весь шаг
|
||||
«Переставить метку» и его окружение), `PROCESS.md` (правило точного кандидата),
|
||||
`scripts/process-reconcile.mjs:342-351` (`relabel`, на которую опирается новый скрипт);
|
||||
- `node --test --test-name-pattern="#706" test/status-label.test.mjs` — 4/4 зелёных;
|
||||
- оба новых мутанта применены руками к копии дерева (`/tmp`, не в рабочей копии) и
|
||||
проверено, что целевой юнит-тест красится:
|
||||
- `rereview-relabel-in-one-call` (патч `scripts/status-label.mjs`, `if (from === to)`
|
||||
→ `if (false)`) — тест `#706 rereview` падает: `expected: 'relabeled', actual: 'moved'`;
|
||||
- `process-label-step-combined-again` (откат шага на инлайн `gh issue edit`) — тест
|
||||
`#706 шаг конвейера` падает на `assert.doesNotMatch(.../gh issue edit/)`;
|
||||
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` —
|
||||
«Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)», браузерные смоки не
|
||||
выбираются — верно: правка не трогает `src/**`;
|
||||
- `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/_process.yml'))"` —
|
||||
YAML синтаксически валиден после правки шага;
|
||||
- `node --check scripts/status-label.mjs` — синтаксис ОК;
|
||||
- прослежена совместимость сигнатур: локальный `gh(args, {allowFailure})` в
|
||||
`status-label.mjs` вызывается `relabel(repo, {number: issue}, to, execute)` из
|
||||
`process-reconcile.mjs` теми же двумя аргументами (`execute(args)` и
|
||||
`execute(args, {allowFailure: true})`) — контракт совпадает;
|
||||
- проверено, что заявленный `cancel-in-progress: false` в группе
|
||||
`concurrency: process-issue-${{ github.event.issue.number }}` действительно стоит на
|
||||
всех четырёх job (`_process.yml:53-55, 317-319, 974-976, 1350-1352`) — довод автора
|
||||
«повторный `labeled` не отменяет текущий прогон, а встаёт в очередь» подтверждён;
|
||||
без этого перестановка метки из середины ещё выполняющегося workflow-прогона могла
|
||||
бы породить гонку;
|
||||
- проверено, что комбинация «одна и та же метка на обоих концах» больше нигде в
|
||||
`_process.yml` не встречается кроме исправленного места (`grep` по
|
||||
`add-label.*remove-label`): строки 462, 702, 822 — везде разные метки на входе и
|
||||
выходе, тот же класс бага там не воспроизводится, latent-дефект не пропущен;
|
||||
- запущен полный `npm run gate:small` в фоне как избыточная перепроверка (дублирует
|
||||
уже зелёный Validate) — не блокировал вывод вердикта; на момент публикации документа
|
||||
ещё выполнялся, к находкам не привёл ни на одном пройденном шаге.
|
||||
|
||||
## AC · чем доказан · чем краснеет
|
||||
|
||||
| AC | Доказательство | Чем краснеет | Проверено |
|
||||
|---|---|---|---|
|
||||
| AC1: `FROM == TO` → снятие, затем постановка, задача остаётся в `S7-code-review` | `#706 rereview` | `rereview-relabel-in-one-call` | тест зелёный; мутант применён вручную — тест краснеет (см. выше) |
|
||||
| AC2: `FROM ≠ TO` → одна правка; без `FROM` — только постановка | `#706 обычный исход` | — (поведение до #706, регрессии не требуется) | тест зелёный, прочитан код ветки `moved` |
|
||||
| AC3: сбой повторной постановки роняет шаг; в шаге нет совмещённого `gh issue edit` | `#706 сбой…`, `#706 шаг конвейера` | `process-label-step-combined-again` (для второй половины); восстановление — мутанты `relabel` из #555 | тесты зелёные; мутант применён вручную — тест краснеет (см. выше) |
|
||||
|
||||
Все три AC — защитные (гард против «задача осталась без статуса молча»), у каждого
|
||||
есть строка «чем доказан/чем краснеет» с результатом прогона, пустых столбцов нет.
|
||||
|
||||
## Находки
|
||||
|
||||
Не найдено. Синтаксис аргументов CLI (`arg()` в `status-label.mjs`) проверен вручную
|
||||
на всех четырёх флагах (`--repo=`, `--issue=`, `--from=`, `--to=`) — смещение среза
|
||||
`name.length + 3` совпадает с длиной префикса `--{name}=` для каждого; ошибки на
|
||||
пустом `--from=` (случай без исходной метки) не возникает, т.к. пустая строка — валидный
|
||||
случай по AC2.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- `moveStatusLabel` в обычном случае (`FROM ≠ TO`) даёт байт-в-байт то же поведение,
|
||||
что и прежний инлайн `gh issue edit --add-label TO --remove-label FROM` — регрессии
|
||||
для всех исходов, кроме `rereview`, нет.
|
||||
- Обработка ошибок сохраняет прежнюю семантику шага: неудача любого `gh`-вызова
|
||||
бросает исключение → `console.error('::error::...')` → `process.exit(1)` →
|
||||
шаг падает → срабатывает последующий `if: failure()` («Позвать владельца»), как и
|
||||
раньше при падении инлайн-команды.
|
||||
- `PROCESS.md` (§ про точного кандидата) обновлён в том же диффе и согласован с кодом:
|
||||
описывает именно снятие-и-постановку при разошедшемся patch-id, а не общий случай.
|
||||
- User-Visible-трейлер не требуется и не заявлен — правка невидима пользователю
|
||||
карточки (внутренний процесс ревью), changelog не тронут — это ожидаемо.
|
||||
- Тесты корректно чувствительны: `#706 шаг конвейера` проверяет и наличие вызова
|
||||
скрипта, и отсутствие возврата к `gh issue edit` — не только позитивную, но и
|
||||
негативную сторону структуры шага.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Живой прогон `rereview` на самом конвейере — по признанию автора, воспроизводится
|
||||
только при следующем слиянии со сменой patch-id; не гейт этого ревью (сценарий по
|
||||
своей природе недоступен до реального конкурентного слияния).
|
||||
- `npm run gate:small`, `process-gate --range`, `mutation-gate --check`,
|
||||
диф-мутанты по 6 шардам (162/162) — заявлены автором, не перегонял; `tsc --noEmit`,
|
||||
`npm test`, `npm run build` покрыты цитированным зелёным Validate на этом SHA и
|
||||
повторно не гонялись согласно правилу дешёвых гейтов; `npm run gate:small` запущен
|
||||
фоново как необязательная подстраховка, не как обязательный гейт этого раунда.
|
||||
- `actionlint` — бинарь недоступен в этом окружении; синтаксис шага проверен вручную
|
||||
построчным сравнением с `find`/`replace` мутанта и YAML-парсером Python, но
|
||||
actionlint-специфичные проверки (например, контекст `steps.*.outputs` в других
|
||||
частях файла) не перегонялись.
|
||||
- Golden, pytest, инварианты модели — диф не трогает `src/**`, рендер, геометрию или
|
||||
Python-бэкенд; по правилам объёма гейтов не применимы, что подтверждено
|
||||
`smoke-select.mjs`, а не только предположением.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. Все три AC доказаны тестами, которые я проверил на способность падать
|
||||
(применил оба мутанта вручную к копии дерева). Класса A нет, User-Visible не требуется,
|
||||
изменение локально и соразмерно заявленной сложности 2/10.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/706-rereview-label`, коммит `ae0e516af16e` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `2441daa60197945e66175706f34258bb589764c7`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 2441daa60197
|
||||
```
|
||||
- Тело issue: `c4af86c1bc1383ec564be59c70334fb4158021c86e84c0d13f23951152fc619d`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
+14
-1
@@ -1,9 +1,21 @@
|
||||
# Индекс ревью
|
||||
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 176, issue: 82. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 189, issue: 90. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
|
||||
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|
||||
|---|---|---|---|---:|---:|---|---|
|
||||
| #706 | [CODE-REVIEW-706-r1.md](CODE-REVIEW-706-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #702 | [CODE-REVIEW-702-r1.md](CODE-REVIEW-702-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #701 | [CODE-REVIEW-701-r1.md](CODE-REVIEW-701-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #700 | [CODE-REVIEW-700-r1.md](CODE-REVIEW-700-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | gh issue list fail-open вместо fail-safe в новом шаге создания issue | `.github/workflows/validate.yml` `.github/workflows/_mutation-gate.yml` |
|
||||
| #700 | [CODE-REVIEW-700-r2.md](CODE-REVIEW-700-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #700 | [CODE-REVIEW-700-r4.md](CODE-REVIEW-700-r4.md) | code · r4 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #699 | [CODE-REVIEW-699-r1.md](CODE-REVIEW-699-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | Новый шаг «опустить потолки на бете» не попал в единственный канонический release-runbo…; Оркестрация tighten (ветка command === 'tighten' в scripts/ratchets.mjs) не покрыта авт… | `docs/DEVELOPMENT.md` `RELEASE-NOTES.md` `PROCESS.md` `scripts/ratchets.mjs` `release-prerelease.mjs` |
|
||||
| #699 | [CODE-REVIEW-699-r2.md](CODE-REVIEW-699-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #699 | [CODE-REVIEW-699-r3.md](CODE-REVIEW-699-r3.md) | code · r3 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #698 | [CODE-REVIEW-698-r1.md](CODE-REVIEW-698-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | PROCESS.md утверждает существование «теста полосы на Validate» для монолитного бейзлайн…; scripts/rebase-on-dev.mjs: предиктивный --dry-run-лог (splitConflicts/predicted.manual)… | `PROCESS.md` `scripts/monolith-baseline.json` `scripts/rebase-on-dev.mjs` `docs/CHANGELOG.md` `docs/CHANGELOG.ru.md` |
|
||||
| #698 | [CODE-REVIEW-698-r2.md](CODE-REVIEW-698-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #697 | [CODE-REVIEW-697-r1.md](CODE-REVIEW-697-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #696 | [CODE-REVIEW-696-r1.md](CODE-REVIEW-696-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #695 | [CODE-REVIEW-695-r1.md](CODE-REVIEW-695-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | «инфраструктура без метки трека» не читается как track:show нигде в коде | `PROCESS.md` `.github/workflows/_process.yml` `scripts/task-packet.mjs` `_process.yml` `task-packet.mjs` `docs/process/AUTHOR.md` `test/task-packet.test.mjs` |
|
||||
| #695 | [CODE-REVIEW-695-r2.md](CODE-REVIEW-695-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
@@ -11,6 +23,7 @@
|
||||
| #691 | [SPEC-REVIEW-691-r2.md](SPEC-REVIEW-691-r2.md) | spec · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #691 | [CODE-REVIEW-691-r1.md](CODE-REVIEW-691-r1.md) | code · r1 | 🟡 жёлтый | 0 | 2 | docs/USER-GUIDE.ru.md не обновлён и описывает старое поведение; существующий регрессионный смок demo/smoke_pan_any_zoom.mjs красный на материале ревью | `docs/USER-GUIDE.ru.md` `AGENTS.md` `docs/process/REVIEWER.md` `docs/SCOPE.md` `docs/USER-GUIDE.md` `docs/TOUCH-SUPPORT.md` `demo/smoke_pan_any_zoom.mjs` `docs/CANVAS.md` |
|
||||
| #691 | [CODE-REVIEW-691-r2.md](CODE-REVIEW-691-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #690 | [CODE-REVIEW-690-r1.md](CODE-REVIEW-690-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #689 | [SPEC-REVIEW-689-r1.md](SPEC-REVIEW-689-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #689 | [CODE-REVIEW-689-r1.md](CODE-REVIEW-689-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
| #688 | [SPEC-REVIEW-688-r1.md](SPEC-REVIEW-688-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
|
||||
|
||||
@@ -8,13 +8,8 @@
|
||||
|
||||
Здесь остались только ТЗ, на которые ссылаются живые код, тесты и документы (ADR, `ISOMETRIC.md`, `SUN.md`, `RADAR.md`, `LIGHT.md`, `DECOR-EDITOR.md`, support-relay), и те, на которые ссылаются они сами. Остальные ТЗ выпущенных задач перенесены в [`legacy/specs/`](../../legacy/specs/) (#682): историю не переписываем, ссылки из документов ревью ведут по SHA и живут дальше. Каталог нужен `scripts/task-packet.mjs` и проверке 3 `scripts/process-gate.mjs`.
|
||||
|
||||
## Обязательные release-артефакты ТЗ
|
||||
## Правила ТЗ
|
||||
|
||||
Если задача меняет пользовательское поведение, её ТЗ обязано явно перечислить:
|
||||
|
||||
- записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
|
||||
- затронутую пользовательскую документацию;
|
||||
- требуемые screenshots/golden и способ их review, если меняется визуал;
|
||||
- release/performance/security artifacts, если они входят в acceptance gate.
|
||||
|
||||
Отсутствие этого раздела не означает, что документация необязательна. Для чистого refactoring ТЗ должно прямо зафиксировать отсутствие пользовательских изменений и перечислить технические доказательства безопасного поведения.
|
||||
Здесь не живут (#701): архив правил не задаёт. Обязательные разделы ТЗ, включая
|
||||
release-артефакты (changelog RU+EN, документация, скриншоты и golden, perf и
|
||||
security, если они входят в приёмку), — `PROCESS.md` §7.1 и DoR `S5-ready` (§2).
|
||||
|
||||
+25
-28
@@ -389,6 +389,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
|
||||
* границы полосы; общий бюджет 301 066 Б не меняется.
|
||||
*/
|
||||
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
|
||||
/**
|
||||
* #699 (решение владельца 2026-09-28): полоса — над потолком, а не под ним.
|
||||
* Задача может вырасти не больше чем на полосу над потолком беты, падение её не
|
||||
* красит; «перецентрирование» на задаче ушло — потолок ставит до факта бета
|
||||
* (`node scripts/ratchets.mjs tighten`). Прежде правило было двусторонним,
|
||||
* `[ceiling - band, ceiling]`, и задача у верхней границы поднимала потолок
|
||||
* сама — как #691. Абсолютный бюджет `INITIAL_VIEW_GZIP_BUDGET` остаётся стеной.
|
||||
*/
|
||||
export const INITIAL_VIEW_CEILING_BAND = 2_000;
|
||||
|
||||
/**
|
||||
@@ -535,24 +543,16 @@ export function initialViewCeilingViolation(bytes, {
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return { kind: 'missing', text: 'initial View graph не измерен — потолок проверить нечем' };
|
||||
}
|
||||
if (bytes > ceiling) {
|
||||
if (bytes > ceiling + band) {
|
||||
return {
|
||||
kind: 'grew',
|
||||
over: bytes - ceiling,
|
||||
text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
text: `initial View graph ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
|
||||
+ ` — больше полосы ${band} B.`
|
||||
+ ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый'
|
||||
+ ' граф (история: #367 → #474). Молча расти этому графу больше нечем.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
return {
|
||||
kind: 'shrank',
|
||||
under: ceiling - bytes,
|
||||
text: `initial View graph ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш граф`
|
||||
+ ' отыграет обратно, и это уже происходило (#367, закрыт).',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -676,24 +676,17 @@ export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_G
|
||||
if (!Number.isFinite(bytes)) {
|
||||
return { kind: 'missing', text: `${label} не измерен — потолок проверить нечем` };
|
||||
}
|
||||
if (bytes > ceiling) {
|
||||
// #699: та же полоса над потолком беты, что у стартового графа.
|
||||
if (bytes > ceiling + band) {
|
||||
return {
|
||||
kind: 'grew',
|
||||
over: bytes - ceiling,
|
||||
text: `${label} ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
|
||||
text: `${label} ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
|
||||
+ ` — больше полосы ${band} B.`
|
||||
+ ' Ленивый граф грузится не в первом кадре, но он всё равно чей-то трафик:'
|
||||
+ ' поднимите потолок в этом же коммите, объяснив рост, либо уменьшите содержимое.',
|
||||
};
|
||||
}
|
||||
if (bytes < ceiling - band) {
|
||||
return {
|
||||
kind: 'shrank',
|
||||
under: ceiling - bytes,
|
||||
text: `${label} ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
|
||||
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш`
|
||||
+ ' граф отыграет обратно.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -819,27 +812,31 @@ export function assertBundleBudget(
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
|
||||
// #699: потолок беты с полосой судится до абсолютного бюджета. С полосой
|
||||
// над потолком граница `ceiling + band` может лежать выше бюджета, и тогда
|
||||
// рост краснел бы только бюджетом — а отказ обязан называть храповик,
|
||||
// который его поймал, и проверка потолка — оставаться исполняемой.
|
||||
const ceiling = initialViewCeilingViolation(manifest.initialViewGzipBytes);
|
||||
if (ceiling) throw new Error(ceiling.text);
|
||||
const result = assertBundleBudget(manifest);
|
||||
assertSupportBundleOwnership(manifest);
|
||||
assertNamespaceLocaleOwnership(manifest);
|
||||
const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);
|
||||
if (ceiling) throw new Error(ceiling.text);
|
||||
const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes;
|
||||
const lines = [
|
||||
`initial View: ${result.initialViewGzipBytes} B gzip`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B ±${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B +${INITIAL_VIEW_CEILING_BAND},`
|
||||
+ ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`,
|
||||
`initial panel: ${result.initialPanelGzipBytes} B gzip`,
|
||||
`initial panel-only: ${result.initialPanelOnlyGzipBytes} B gzip`
|
||||
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
|
||||
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy onboarding: ${result.lazyOnboardingGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy namespace locales: ${result.lazyNamespaceLocaleGzipBytes} B gzip`
|
||||
+ ` (${NAMESPACE_LOCALE_CHUNKS.length} chunks, по одному грузится на пространство)`,
|
||||
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
|
||||
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
|
||||
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
|
||||
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
|
||||
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Классы изменений, PROCESS.md §1 — одна таблица на все гейты (#701).
|
||||
//
|
||||
// Вынесена из process-gate.mjs: её читает и `validate-commit-provenance.mjs`
|
||||
// (хук commit-msg), которому process-gate сам импортирует — общий модуль
|
||||
// снимает круговую зависимость.
|
||||
// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A,
|
||||
// а demo/golden/baselines — в B.
|
||||
const CLASS_D = [
|
||||
/^dist\//,
|
||||
/^custom_components\/houseplan\/frontend\//,
|
||||
/^demo\/srv\/assets\/houseplan-card\.js$/,
|
||||
/^demo\/golden\/baselines\//,
|
||||
];
|
||||
const CLASS_A = [
|
||||
/^src\//,
|
||||
/^custom_components\/houseplan\/.*\.py$/,
|
||||
/^hacs\.json$/,
|
||||
/^custom_components\/.*\/manifest\.json$/,
|
||||
/^custom_components\/.*\/translations\//,
|
||||
];
|
||||
const CLASS_B = [
|
||||
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
|
||||
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
|
||||
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
|
||||
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
|
||||
/^\.nvmrc$/, /^\.python-version$/,
|
||||
];
|
||||
const CLASS_C = [
|
||||
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
|
||||
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
|
||||
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
|
||||
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
|
||||
/^legacy\//,
|
||||
];
|
||||
|
||||
|
||||
export function classify(path) {
|
||||
if (CLASS_D.some((r) => r.test(path))) return 'D';
|
||||
if (CLASS_A.some((r) => r.test(path))) return 'A';
|
||||
if (CLASS_B.some((r) => r.test(path))) return 'B';
|
||||
if (CLASS_C.some((r) => r.test(path))) return 'C';
|
||||
return '?';
|
||||
}
|
||||
@@ -9,7 +9,12 @@ import { freshnessSink, screenshotsMode } from './docs-freshness.mjs';
|
||||
import { guideParityErrors } from './user-guide-parity.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const EXTERNAL = process.argv.includes('--external');
|
||||
// `--external` — внешние ссылки судятся ошибкой; `--external=warn` — только
|
||||
// предупреждением (#700): упавший чужой сайт не красит ветку задачи, к чьему
|
||||
// изменению он отношения не имеет. Блокируют внешние ссылки push в dev,
|
||||
// кандидат беты и релиз — там их чинит релиз-менеджер.
|
||||
const EXTERNAL_WARN = process.argv.includes('--external=warn');
|
||||
const EXTERNAL = EXTERNAL_WARN || process.argv.includes('--external');
|
||||
const PUBLIC_DOCS = [
|
||||
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
|
||||
'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md',
|
||||
@@ -233,6 +238,7 @@ if (!existsSync(manifestPath)) {
|
||||
}
|
||||
|
||||
if (EXTERNAL) {
|
||||
const externalErrors = EXTERNAL_WARN ? warnings : errors;
|
||||
const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json')));
|
||||
const transientHosts = new Set(allowlist.transientHosts || []);
|
||||
for (const href of [...externalUrls].sort()) {
|
||||
@@ -249,10 +255,10 @@ if (EXTERNAL) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
errors.push(`external link returned ${response.status}: ${href}`);
|
||||
externalErrors.push(`external link returned ${response.status}: ${href}`);
|
||||
} catch (error) {
|
||||
if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`);
|
||||
else errors.push(`external link failed: ${href} (${error.message})`);
|
||||
else externalErrors.push(`external link failed: ${href} (${error.message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,12 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
/** Маршруты входа по роли. `budget: null` — только замер, без порога. */
|
||||
export const ROUTES = Object.freeze({
|
||||
// #701: `docs/STATUS.md` ушёл из входа автора — снимок версий и цикла нужен,
|
||||
// когда сессия возобновляет работу или готовит релиз, а задачу ведёт её пакет
|
||||
// (`task-packet.mjs`). Минус 691 слово на каждом входе.
|
||||
author: {
|
||||
budget: 12000, // AC1 #634
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md', 'docs/STATUS.md'],
|
||||
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md'],
|
||||
},
|
||||
reviewer: {
|
||||
budget: 9000,
|
||||
|
||||
+11
-4
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// Локальный гейт лёгкого трека одной командой (#479): `npm run gate:small`.
|
||||
//
|
||||
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
|
||||
// Этот файл — единственный источник состава обязательной части §8 (#701):
|
||||
// канон и конспекты его не переписывают, а называют. В #476 команды гонялись
|
||||
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
|
||||
// обязательная часть §8 начинается параллельно — сборка с typecheck, «новый
|
||||
// код не добавляет any», выбор смоков по диффу. Юниты читают свежий `dist`,
|
||||
@@ -35,10 +36,13 @@ export function parseArgs(argv) {
|
||||
return { base, smokes, jobs: Number.isInteger(jobs) && jobs > 0 ? jobs : 2 };
|
||||
}
|
||||
|
||||
/** Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без «широких». */
|
||||
/**
|
||||
* Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без
|
||||
* «широких»; при недоказанной связи — ещё визуальный минимум (#690 п.1′).
|
||||
*/
|
||||
export function smokesToRun(selection) {
|
||||
if (!selection || selection.noExecutableDiff) return [];
|
||||
const names = [...(selection.direct || []), ...(selection.registered || [])]
|
||||
const names = [...(selection.direct || []), ...(selection.registered || []), ...(selection.visualMinimum || [])]
|
||||
.map((entry) => (typeof entry === 'string' ? entry : entry.smoke))
|
||||
.filter(Boolean);
|
||||
return [...new Set(names)].sort();
|
||||
@@ -143,7 +147,10 @@ export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = fals
|
||||
else if (selection.noExecutableDiff) log('smoke-select: исполняемого frontend-диффа нет — смоки этим диффом не выбираются');
|
||||
else {
|
||||
const names = smokesToRun(selection);
|
||||
log(`smoke-select: прямые и зарегистрированные (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`);
|
||||
const what = (selection.visualMinimum || []).length
|
||||
? 'связь не доказана — прямые, зарегистрированные и визуальный минимум (#690)'
|
||||
: 'прямые и зарегистрированные';
|
||||
log(`smoke-select: ${what} (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`);
|
||||
for (const name of names) log(` demo/${name}`);
|
||||
if ((selection.broad || []).length) log(` «широких» символов: ${selection.broad.length} — решает ревьюер, автоматически не гоняются`);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ import { CONVEYOR_IDENTITY } from './reviews-index.mjs';
|
||||
const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
|
||||
|
||||
export const MAX_ATTEMPTS = 3;
|
||||
/** Пути вне patch-id кандидата (#698): документы ревью и то, что ребейз сливает сам. */
|
||||
export const PATCH_ID_EXCLUDES = Object.freeze([
|
||||
':!docs/reviews', ':!docs/CHANGELOG.md', ':!docs/CHANGELOG.ru.md', ':!scripts/monolith-baseline.json',
|
||||
]);
|
||||
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
|
||||
export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
|
||||
|
||||
@@ -97,7 +101,9 @@ export function commentFor(action, ctx) {
|
||||
case 'fast-forward':
|
||||
return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\``
|
||||
+ (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался')
|
||||
+ ' · слито';
|
||||
+ ' · слито'
|
||||
+ (ctx.branchDeleted === true ? ` · ветка \`${ctx.branch}\` удалена` : '')
|
||||
+ (ctx.branchDeleted === false ? ` · ветка \`${ctx.branch}\` оставлена: её вершина сдвинулась после слияния` : '');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -145,8 +151,11 @@ export function realOps({
|
||||
// Документы ревью — не часть патча (#516): кандидат несёт свой
|
||||
// CODE-REVIEW-N-rK.md, материал — нет, и без pathspec их patch-id
|
||||
// расходились на каждом сдвиге dev; `reviewedFresh` судит так же.
|
||||
// #698: вердикт судит работу задачи. Ченджлоги объединяет `merge=union`,
|
||||
// базу метрик монолита ребейз берёт из dev: строки соседей рядом с записью
|
||||
// задачи меняют контекст диффа, но не то, что читал ревьюер.
|
||||
patchId: (from, to) => {
|
||||
const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');
|
||||
const diff = must(git('diff', '--full-index', from, to, '--', '.', ...PATCH_ID_EXCLUDES), 'diff');
|
||||
const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' });
|
||||
return (r.stdout || '').trim().split(' ')[0] || 'empty';
|
||||
},
|
||||
@@ -179,6 +188,14 @@ export function realOps({
|
||||
must(exec(process.execPath, [REVIEWS_INDEX_SCRIPT, '--dir=docs/reviews', '--commit-if-stale', `--issue=${issue}`]), 'reviews-index --commit-if-stale');
|
||||
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
|
||||
},
|
||||
// #702: ветка задачи удаляется после слияния — только если её вершина всё
|
||||
// ещё та, что влита (lease): коммит, прилетевший после, не теряется.
|
||||
deleteBranch: (ref, expected) => {
|
||||
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `:refs/heads/${ref}`);
|
||||
if (r.status === 0) return true;
|
||||
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
|
||||
throw new Error(`git push :${ref}: ${r.stderr}`);
|
||||
},
|
||||
pushWithLease: (sha, ref, expected) => {
|
||||
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`);
|
||||
if (r.status === 0) return true;
|
||||
@@ -243,9 +260,18 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
&& ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0);
|
||||
const ctx = { branch, material, actual, issue };
|
||||
const finish = (decision, extra = {}) => {
|
||||
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt });
|
||||
if (body) ops.comment(issue, body);
|
||||
const merged = decision.action === 'push' || decision.action === 'fast-forward';
|
||||
// #702: влитая ветка больше не нужна — 368 таких висели на origin, и агент,
|
||||
// искавший ветку по номеру, мог взять устаревшую. `branchTip` — вершина,
|
||||
// которую слияние видело последней: кандидат, опубликованный в ветку, либо
|
||||
// материал при fast-forward. Сбой удаления слияние не отменяет.
|
||||
let branchDeleted = null;
|
||||
if (merged && extra.branchTip) {
|
||||
try { branchDeleted = ops.deleteBranch(branch, extra.branchTip); }
|
||||
catch (error) { ops.log(`ветка ${branch} не удалена: ${error.message}`); }
|
||||
}
|
||||
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt, branchDeleted });
|
||||
if (body) ops.comment(issue, body);
|
||||
ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`);
|
||||
return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual };
|
||||
};
|
||||
@@ -265,7 +291,7 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
const pushed = ops.pushWithLease(target, 'dev', devNow);
|
||||
const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed });
|
||||
if (decision.action === 'retry') continue;
|
||||
return finish(decision, { candidate: target, devNow });
|
||||
return finish(decision, { candidate: target, devNow, branchTip: tip });
|
||||
}
|
||||
|
||||
const candidate = ops.rebaseOnto(tip, 'origin/dev');
|
||||
@@ -310,7 +336,7 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
const pushed = ops.pushWithLease(candidate, 'dev', devNow);
|
||||
decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts });
|
||||
if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; }
|
||||
return finish(decision, { candidate, devNow, runUrl: url, attempt });
|
||||
return finish(decision, { candidate, devNow, runUrl: url, attempt, branchTip: candidate });
|
||||
}
|
||||
return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts });
|
||||
}
|
||||
|
||||
@@ -261,17 +261,28 @@ export function collectMetrics(root, { diagnostics = null, distDir = 'dist' } =
|
||||
*/
|
||||
export const BUNDLE_BYTES_BAND = 2_000;
|
||||
|
||||
/** Сравнение с базой: что выросло, что упало. Отсутствующее в базе — рост. */
|
||||
export function compareWithBaseline(current, baseline, { bundleBand = BUNDLE_BYTES_BAND } = {}) {
|
||||
/**
|
||||
* #699 (решение владельца 2026-09-28): полоса над базой беты у всех шести чисел.
|
||||
* Рост `hostRefs` на несколько ссылок больше не блокирует задачу `show`, а
|
||||
* параллельные задачи не конфликтуют на базе и не пересчитывают её после
|
||||
* ребейза. Снижение задачу не красит: базу до факта опускает бета
|
||||
* (`node scripts/ratchets.mjs tighten`).
|
||||
*/
|
||||
export const METRIC_BANDS = Object.freeze({
|
||||
delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND,
|
||||
});
|
||||
|
||||
/** Сравнение с базой: что выросло сверх полосы, что упало. Отсутствующее в базе — рост. */
|
||||
export function compareWithBaseline(current, baseline, { bands = METRIC_BANDS } = {}) {
|
||||
const grown = [];
|
||||
const shrunk = [];
|
||||
for (const name of METRIC_NAMES) {
|
||||
const now = current[name];
|
||||
const base = baseline?.[name];
|
||||
if (now == null) continue;
|
||||
const band = name === 'bundleBytes' ? bundleBand : 0;
|
||||
const band = bands[name] ?? 0;
|
||||
if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });
|
||||
else if (now < base - band) shrunk.push({ name, base, now });
|
||||
else if (now < base) shrunk.push({ name, base, now });
|
||||
}
|
||||
return { grown, shrunk };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Ориентир, а не стена (#699, решение владельца 2026-09-28). Прежний жёсткий
|
||||
* лимит заставлял новую задачу удалять чужой браузерный мутант или уводить свой
|
||||
* свидетель в Node только ради числа (#687, #689). Каждый browser guard и так
|
||||
* обязан иметь строку обоснования в реестре; сверх ориентира `--check`
|
||||
* предупреждает, а не краснеет.
|
||||
*/
|
||||
export const BROWSER_GUARD_LIMIT = 200;
|
||||
export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md';
|
||||
|
||||
|
||||
@@ -42,8 +42,11 @@ export function checkMutationRegistry(selected, { allMutants, root, log = consol
|
||||
const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root));
|
||||
log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`);
|
||||
if (policy.overLimit) {
|
||||
log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`);
|
||||
stale++;
|
||||
// #699: ориентир, а не лимит — цена браузерных свидетелей растёт, но
|
||||
// удалять чужой мутант ради числа не нужно.
|
||||
log(`WARN browser guards: ${policy.count} при ориентире ${BROWSER_GUARD_LIMIT} — `
|
||||
+ 'каждый сверх ориентира держится своей строкой обоснования в реестре');
|
||||
warned++;
|
||||
}
|
||||
for (const mutant of policy.missingReasons) {
|
||||
log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в `
|
||||
|
||||
@@ -3623,11 +3623,11 @@ const MUTANT_DEFINITIONS = [
|
||||
{
|
||||
id: 'task-packet-trivial-is-product-flow',
|
||||
guard: 'node --test test/task-packet.test.mjs',
|
||||
because: 'the trivial short track writes no spec and no spec review, so in S6/S7 its label is '
|
||||
because: 'a legacy trivial task wrote no spec and no spec review, so in S6/S7 its label is '
|
||||
+ 'the only product-flow evidence; dropping it prints the false class A ban of #632 again (r1)',
|
||||
patches: [{
|
||||
file: 'scripts/task-packet.mjs',
|
||||
find: " if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)');",
|
||||
find: " if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)');",
|
||||
replace: "",
|
||||
}],
|
||||
},
|
||||
@@ -4069,6 +4069,29 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' return safe;',
|
||||
}],
|
||||
},
|
||||
// #706: исход rereview возвращает задачу в ту же метку; один вызов gh её снимал.
|
||||
{
|
||||
id: 'rereview-relabel-in-one-call',
|
||||
guard: 'node --test --test-name-pattern="#706 rereview" test/status-label.test.mjs',
|
||||
because: '#706: --add-label X --remove-label X in one gh call leaves the issue without a status; '
|
||||
+ 'the same label must be removed and set again so the labeled event starts a new round',
|
||||
patches: [{
|
||||
file: 'scripts/status-label.mjs',
|
||||
find: ' if (from === to) {',
|
||||
replace: ' if (false) { // mutant: one combined call',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-label-step-combined-again',
|
||||
guard: 'node --test --test-name-pattern="#706 шаг конвейера" test/status-label.test.mjs',
|
||||
because: '#706: the pipeline step must move the label through status-label.mjs, not through the '
|
||||
+ 'combined gh call that stripped S7-code-review on rereview',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' node scripts/status-label.mjs --repo="${{ github.repository }}" \\\n --issue="$NUM" --from="$FROM" --to="$TO"\n',
|
||||
replace: ' gh issue edit "$NUM" --repo "${{ github.repository }}" \\\n --add-label "$TO" --remove-label "$FROM"\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'vac-trail-drop-warn-removed',
|
||||
guard: 'node --test --test-name-pattern="#369" test/vacuum.test.mjs',
|
||||
@@ -5509,6 +5532,18 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(",
|
||||
}],
|
||||
},
|
||||
// #702: влитая ветка задачи удаляется слиянием.
|
||||
{
|
||||
id: 'merged-task-branch-kept',
|
||||
guard: 'node --test --test-name-pattern="#702" test/merge-candidate.test.mjs',
|
||||
because: '#702: 368 merged issue/* branches piled up on origin; an agent looking a branch up by '
|
||||
+ 'number could take a stale one — the merge deletes the branch it merged',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: ' if (merged && extra.branchTip) {',
|
||||
replace: ' if (false && merged && extra.branchTip) { // mutant: branch kept',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'nightly-does-not-wait',
|
||||
guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs',
|
||||
@@ -6132,6 +6167,40 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " status: degradedExtraCount || degradedCoreCount ? 'failed-core' : 'ok',",
|
||||
}],
|
||||
},
|
||||
// #690 п.1′: недоказанная связь смоков выдаёт визуальный минимум, а не пустоту.
|
||||
{
|
||||
id: 'visual-minimum-silent-again',
|
||||
guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs',
|
||||
because: '#690: an executable diff with no proven smoke link printed only "the reviewer decides" '
|
||||
+ 'and ran nothing; #687 missed smoke_modes that way and left dev red for #685',
|
||||
patches: [{
|
||||
file: 'scripts/smoke-select.mjs',
|
||||
find: ' visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],',
|
||||
replace: ' visualMinimum: [],',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'visual-minimum-on-proven-link',
|
||||
guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs',
|
||||
because: '#690: the minimum is for the unproven case only; with a proven link the selection must '
|
||||
+ 'stay smaller than the matrix',
|
||||
patches: [{
|
||||
file: 'scripts/smoke-select.mjs',
|
||||
find: ' visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],',
|
||||
replace: ' visualMinimum: parsed.executable.length ? [...VISUAL_MINIMUM] : [],',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'gate-small-skips-visual-minimum',
|
||||
guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs',
|
||||
because: '#690: gate:small -- --smokes must run the visual minimum it was handed, not only the '
|
||||
+ 'direct and registered smokes',
|
||||
patches: [{
|
||||
file: 'scripts/gate-small.mjs',
|
||||
find: ' const names = [...(selection.direct || []), ...(selection.registered || []), ...(selection.visualMinimum || [])]',
|
||||
replace: ' const names = [...(selection.direct || []), ...(selection.registered || [])]',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'wall-isolated-extra-discarded',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
@@ -6928,6 +6997,19 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " if (false && input.type === 'passage') {",
|
||||
}],
|
||||
},
|
||||
// #693: курсор move над лестницей — только у редактора плана.
|
||||
{
|
||||
id: 'view-stair-cursor-move-again',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="#693" test/stairs.test.mjs',
|
||||
because: '#693: the View layer sets input-enabled only to receive clicks; an unscoped move cursor on '
|
||||
+ '.hp-stair-hit hid the link pointer and showed a drag the View cannot do',
|
||||
patches: [{
|
||||
file: 'src/styles/plan.styles.ts',
|
||||
find: ' .hp-stair.input-enabled:not(.hp-stair-view) .hp-stair-hit { cursor: move; }',
|
||||
replace: ' .hp-stair.input-enabled .hp-stair-hit { cursor: move; }',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entity-marker-kept-in-parent-device',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
@@ -8146,6 +8228,29 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' .hdr > .head { flex-wrap: wrap; padding: 5px 8px; gap: 6px; }',
|
||||
}],
|
||||
},
|
||||
// #701: документационный коммит трейлеров не требует — и только он.
|
||||
{
|
||||
id: 'docs-only-commit-needs-trailers-again',
|
||||
guard: 'node --test --test-name-pattern="#701" test/commit-provenance.test.mjs',
|
||||
because: '#701 (PROCESS §3 п.10): rule #1 guards product code, not a typo in a guide; a docs-only '
|
||||
+ 'commit carries no Issue/User-Visible trailers',
|
||||
patches: [{
|
||||
file: 'scripts/validate-commit-provenance.mjs',
|
||||
find: ' const exempt = isDocsOnlyCommit(changedFiles) && !issues.length && !visible.length;',
|
||||
replace: ' const exempt = false; // mutant: every commit needs trailers',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'docs-only-exemption-leaks-to-code',
|
||||
guard: 'node --test --test-name-pattern="#701" test/commit-provenance.test.mjs',
|
||||
because: '#701: one file outside class C makes the commit subject to rule #1 again; the exemption '
|
||||
+ 'must not cover a commit that also touches src/**',
|
||||
patches: [{
|
||||
file: 'scripts/validate-commit-provenance.mjs',
|
||||
find: " return changedFiles.length > 0 && changedFiles.every((file) => classify(file.replaceAll('\\\\', '/')) === 'C');",
|
||||
replace: " return changedFiles.length > 0 && changedFiles.some((file) => classify(file.replaceAll('\\\\', '/')) === 'C'); // mutant",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'header-menu-drops-pdf',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
@@ -9028,7 +9133,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'release where it would have fired, and the growth of #438 went unnoticed for a beta)',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-budget.mjs',
|
||||
find: ' const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);\n'
|
||||
find: ' const ceiling = initialViewCeilingViolation(manifest.initialViewGzipBytes);\n'
|
||||
+ ' if (ceiling) throw new Error(ceiling.text);\n',
|
||||
replace: '',
|
||||
}],
|
||||
@@ -10348,15 +10453,82 @@ const MUTANT_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'monolith-metrics-baseline-strict',
|
||||
guard: 'node --test --test-name-pattern="#624 храповик" test/monolith-metrics.test.mjs',
|
||||
because: 'the ratchet reddens on a growth of one; a >= comparison lets bundle bytes and '
|
||||
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2)',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: 'the ratchet reddens one unit over the band; a looser comparison lets bundle bytes and '
|
||||
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2; band since #699)',
|
||||
patches: [{
|
||||
file: 'scripts/monolith-metrics.mjs',
|
||||
find: " if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });",
|
||||
replace: " if (base == null || now > base + band + 1) grown.push({ name, base: base ?? null, now }); // mutant: one unit of slack",
|
||||
}],
|
||||
},
|
||||
// #699: полоса над потолком беты вместо двустороннего храповика с нулевым запасом.
|
||||
{
|
||||
id: 'core-band-ignored',
|
||||
guard: 'node --test --test-name-pattern="#699" test/core-file-budget.test.mjs',
|
||||
because: '#699: the core may grow up to 50 lines over the beta ceiling; without the band every '
|
||||
+ 'task behind a neighbour hits a zero-slack ceiling again (#689 after #691)',
|
||||
patches: [{
|
||||
file: 'test/core-file-budget.test.mjs',
|
||||
find: ' if (lines > cap + band) {',
|
||||
replace: ' if (lines > cap) { // mutant: no band',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'initial-view-band-below-ceiling-again',
|
||||
guard: 'node --test --test-name-pattern="#438/#699" test/bundle-assets.test.mjs',
|
||||
because: '#699: the bundle band sits over the beta ceiling; a task no longer re-centres the '
|
||||
+ 'ceiling to grow inside it',
|
||||
patches: [{
|
||||
file: 'scripts/bundle-budget.mjs',
|
||||
find: ' if (bytes > ceiling + band) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
|
||||
replace: ' if (bytes > ceiling) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-band-exact-again',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: '#699: a few more host. references no longer block a show task; the five source '
|
||||
+ 'numbers get a band over the beta baseline',
|
||||
patches: [{
|
||||
file: 'scripts/monolith-metrics.mjs',
|
||||
find: ' const band = bands[name] ?? 0;',
|
||||
replace: " const band = name === 'bundleBytes' ? bands[name] : 0; // mutant: exact again",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-shrink-fails-branch-again',
|
||||
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
|
||||
because: '#699: a lower number is fixed at the beta by ratchets tighten; failing the branch '
|
||||
+ 'makes parallel tasks conflict on the baseline again',
|
||||
patches: [{
|
||||
file: 'scripts/unused-locals-gate.mjs',
|
||||
find: " for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);",
|
||||
replace: " fail = true; for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`); // mutant",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ratchet-report-calls-band-tight',
|
||||
guard: 'node --test --test-name-pattern="#699 состояние" test/ratchets.test.mjs',
|
||||
because: '#699: a fact inside the band over the ceiling must be reported for the beta to raise '
|
||||
+ 'or revert, not silently counted as tight',
|
||||
patches: [{
|
||||
file: 'scripts/ratchets.mjs',
|
||||
find: " if (fact > ceiling) return 'over';\n",
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'release-runbook-forgets-tighten',
|
||||
guard: 'node --test --test-name-pattern="#699 r1 M1" test/ratchets.test.mjs',
|
||||
because: '#699 r1 M1: the second side of the ratchet lives in the candidate checklist; a warning '
|
||||
+ 'at publication time comes after the candidate commit and is ignored systematically',
|
||||
patches: [{
|
||||
file: 'docs/DEVELOPMENT.md',
|
||||
find: 'facts with `node scripts/ratchets.mjs tighten` (#699',
|
||||
replace: 'facts with `node scripts/ratchets.mjs report` (#699',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-delegates-return-only',
|
||||
guard: 'node --test --test-name-pattern="#624 делегаты" test/monolith-metrics.test.mjs',
|
||||
@@ -12129,10 +12301,44 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'that counts docs/reviews sends every green candidate back to review whenever dev moved (#516)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');",
|
||||
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ...PATCH_ID_EXCLUDES), 'diff');",
|
||||
replace: " const diff = must(git('diff', '--full-index', from, to), 'diff'); // mutant: review docs count",
|
||||
}],
|
||||
},
|
||||
// #698: ребейз сам сливает то, в чём две задачи не противоречат друг другу.
|
||||
{
|
||||
id: 'changelog-union-driver-dropped',
|
||||
guard: 'node --test --test-name-pattern="#698: записи ченджлога" test/rebase-generated.test.mjs',
|
||||
because: '#698: two tasks adding lines to ## Unreleased must not send the second one back to S6; '
|
||||
+ 'the built-in union driver merges both sides',
|
||||
patches: [{
|
||||
file: '.gitattributes',
|
||||
find: 'docs/CHANGELOG.md merge=union\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'monolith-baseline-conflict-is-manual-again',
|
||||
guard: 'node --test --test-name-pattern="#698: конфликт в базе" test/rebase-generated.test.mjs',
|
||||
because: '#698: the monolith metrics baseline is a snapshot, not a decision; on a rebase conflict '
|
||||
+ 'dev wins and the band test on the candidate judges the merged tree',
|
||||
patches: [{
|
||||
file: 'scripts/rebase-generated.mjs',
|
||||
find: "export const UPSTREAM_WINS = Object.freeze(['scripts/monolith-baseline.json']);",
|
||||
replace: 'export const UPSTREAM_WINS = Object.freeze([]); // mutant: every baseline conflict is manual',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'merge-patch-id-sees-changelog',
|
||||
guard: 'node --test --test-name-pattern="#698: patch-id" test/rebase-generated.test.mjs',
|
||||
because: '#698: a neighbour line next to the task entry changes the diff context, not what the '
|
||||
+ 'reviewer read; counting it re-sends a green task to review',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ':!docs/reviews', ':!docs/CHANGELOG.md', ':!docs/CHANGELOG.ru.md', ':!scripts/monolith-baseline.json',",
|
||||
replace: " ':!docs/reviews', ':!scripts/monolith-baseline.json',",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'merge-trusts-success-without-proof',
|
||||
guard: 'node --test test/merge-candidate.test.mjs',
|
||||
@@ -12637,6 +12843,40 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " if (d.startsWith('M') || d.length >= 0) return d;",
|
||||
}],
|
||||
},
|
||||
// #700: предполёт не красит ветку задачи чужими причинами.
|
||||
{
|
||||
id: 'task-branch-workflow-sync-red-again',
|
||||
guard: 'node --test --test-name-pattern="#700" test/validate-workflow.test.mjs',
|
||||
because: '#700: a thin workflow mirror mismatch has nothing to do with the task branch; 11 of 85 '
|
||||
+ 'returns in #600–#691 came from it — on issue/* it is a warning, on dev a red preflight',
|
||||
patches: [{
|
||||
file: '.github/workflows/validate.yml',
|
||||
find: ' advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"',
|
||||
replace: ' check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'workflow-sync-issue-duplicated-on-read-failure',
|
||||
guard: 'node --test --test-name-pattern="#700: на ветке задачи" test/validate-workflow.test.mjs',
|
||||
because: 'r1 #700: a failed read of the open issues must not fall through into gh issue create — '
|
||||
+ 'that duplicates the owner issue on every network or rate-limit failure',
|
||||
patches: [{
|
||||
file: '.github/workflows/validate.yml',
|
||||
find: ' exit 0\n fi\n if [ -n "$existing" ]; then',
|
||||
replace: ' :\n fi\n if [ -n "$existing" ]; then',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'external-link-warn-mode-ignored',
|
||||
guard: 'node --test --test-name-pattern="#700: check-docs" test/validate-workflow.test.mjs',
|
||||
because: '#700: a foreign site that is down must not turn a task branch red; --external=warn '
|
||||
+ 'routes external failures to warnings',
|
||||
patches: [{
|
||||
file: 'scripts/check-docs.mjs',
|
||||
find: ' const externalErrors = EXTERNAL_WARN ? warnings : errors;',
|
||||
replace: ' const externalErrors = errors; // mutant: warn mode ignored',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'screenshot-freshness-never-strict',
|
||||
guard: 'node --test --test-name-pattern="#586" test/classify-changes.test.mjs',
|
||||
|
||||
@@ -39,36 +39,11 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { resolveValidationRange } from './validate-commit-provenance.mjs';
|
||||
|
||||
// --- классы изменений, PROCESS.md §1 ---
|
||||
// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A,
|
||||
// а demo/golden/baselines — в B.
|
||||
const CLASS_D = [
|
||||
/^dist\//,
|
||||
/^custom_components\/houseplan\/frontend\//,
|
||||
/^demo\/srv\/assets\/houseplan-card\.js$/,
|
||||
/^demo\/golden\/baselines\//,
|
||||
];
|
||||
const CLASS_A = [
|
||||
/^src\//,
|
||||
/^custom_components\/houseplan\/.*\.py$/,
|
||||
/^hacs\.json$/,
|
||||
/^custom_components\/.*\/manifest\.json$/,
|
||||
/^custom_components\/.*\/translations\//,
|
||||
];
|
||||
const CLASS_B = [
|
||||
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
|
||||
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
|
||||
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
|
||||
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
|
||||
/^\.nvmrc$/, /^\.python-version$/,
|
||||
];
|
||||
const CLASS_C = [
|
||||
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
|
||||
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
|
||||
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
|
||||
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
|
||||
/^legacy\//,
|
||||
];
|
||||
// Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их
|
||||
// читает и хук commit-msg, который судит, нужен ли коммиту трейлер.
|
||||
import { classify } from './change-classes.mjs';
|
||||
|
||||
export { classify };
|
||||
|
||||
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
|
||||
|
||||
@@ -90,13 +65,6 @@ export const RULES = {
|
||||
10: 'DoR по моменту коммита',
|
||||
};
|
||||
|
||||
export function classify(path) {
|
||||
if (CLASS_D.some((r) => r.test(path))) return 'D';
|
||||
if (CLASS_A.some((r) => r.test(path))) return 'A';
|
||||
if (CLASS_B.some((r) => r.test(path))) return 'B';
|
||||
if (CLASS_C.some((r) => r.test(path))) return 'C';
|
||||
return '?';
|
||||
}
|
||||
|
||||
// --- разбор коммитов ---
|
||||
// Тело коммита многострочное, поэтому поля режутся не по переводам строк:
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Храповики беты (#699, PROCESS.md §8).
|
||||
*
|
||||
* node scripts/ratchets.mjs report [--warn] # факт против потолков
|
||||
* node scripts/ratchets.mjs tighten # потолки := факт кандидата
|
||||
*
|
||||
* С #699 задача может вырасти над потолком беты в пределах полосы, а снижение
|
||||
* её не красит. Вторая сторона храповика живёт здесь: релиз-менеджер на
|
||||
* кандидате беты опускает потолки до факта одной командой, и выигрыш,
|
||||
* накопленный линией, фиксируется одним коммитом, а не правкой общих чисел в
|
||||
* каждой ветке — на них параллельные задачи и конфликтовали.
|
||||
*
|
||||
* Потолки лежат там же, где их читают гейты: строки ядер — в
|
||||
* `test/core-file-budget.test.mjs` (CAPS), gzip-графы — в
|
||||
* `scripts/bundle-budget.mjs`, числа связности — в
|
||||
* `scripts/monolith-baseline.json`. Бандл и `bundleBytes` меряются по
|
||||
* собранному `dist/`: на кандидате он закоммичен свежим (`npm run bundle:release`).
|
||||
*
|
||||
* `report --warn` печатает `::warning::` на рыхлые и вышедшие в полосу потолки
|
||||
* и всегда выходит с 0: это напоминание публикации беты, а не её гейт.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
INITIAL_VIEW_CEILING_BAND, LAZY_GRAPH_CEILING_BAND,
|
||||
} from './bundle-budget.mjs';
|
||||
import {
|
||||
BASELINE_FILE, METRIC_BANDS, METRIC_NAMES, collectMetrics, readBaseline,
|
||||
} from './monolith-metrics.mjs';
|
||||
|
||||
export const CORE_BUDGET_FILE = 'test/core-file-budget.test.mjs';
|
||||
export const BUNDLE_BUDGET_FILE = 'scripts/bundle-budget.mjs';
|
||||
/** Полоса ядра — та же, что у теста (#699); тест сверяет, что числа не разошлись. */
|
||||
export const CORE_BAND = 50;
|
||||
|
||||
export const BUNDLE_CEILINGS = Object.freeze([
|
||||
{ name: 'INITIAL_VIEW_GZIP_CEILING', metric: 'initialViewGzipBytes', label: 'initial View', band: INITIAL_VIEW_CEILING_BAND },
|
||||
{ name: 'LAZY_EDITOR_GZIP_CEILING', metric: 'lazyEditorGzipBytes', label: 'lazy editor', band: LAZY_GRAPH_CEILING_BAND },
|
||||
{ name: 'LAZY_ONBOARDING_GZIP_CEILING', metric: 'lazyOnboardingGzipBytes', label: 'lazy onboarding', band: LAZY_GRAPH_CEILING_BAND },
|
||||
{ name: 'LAZY_FURNITURE_ART_GZIP_CEILING', metric: 'lazyFurnitureArtGzipBytes', label: 'lazy furniture art', band: LAZY_GRAPH_CEILING_BAND },
|
||||
]);
|
||||
|
||||
const capsBlock = (source) => {
|
||||
const start = source.indexOf('const CAPS = {');
|
||||
if (start < 0) throw new Error(`${CORE_BUDGET_FILE}: нет блока const CAPS`);
|
||||
const end = source.indexOf('};', start);
|
||||
return { start, end, text: source.slice(start, end) };
|
||||
};
|
||||
|
||||
/** Потолки ядер из исходника теста: `'src/x.ts': 12891,`. */
|
||||
export function readCoreCaps(source) {
|
||||
const caps = {};
|
||||
for (const match of capsBlock(source).text.matchAll(/^\s*'([^']+)':\s*(\d+),\s*$/gm)) caps[match[1]] = Number(match[2]);
|
||||
return caps;
|
||||
}
|
||||
|
||||
/** Тот же исходник с новыми числами; комментарии и порядок — как были. */
|
||||
export function rewriteCoreCaps(source, facts) {
|
||||
const { start, end, text } = capsBlock(source);
|
||||
const next = text.replace(/^(\s*)'([^']+)':\s*(\d+),(\s*)$/gm,
|
||||
(line, indent, file, value, tail) => (file in facts ? `${indent}'${file}': ${facts[file]},${tail}` : line));
|
||||
return source.slice(0, start) + next + source.slice(end);
|
||||
}
|
||||
|
||||
/** `301_000` → 301000. */
|
||||
export function readConst(source, name) {
|
||||
const match = new RegExp(`^export const ${name} = ([\\d_]+);$`, 'm').exec(source);
|
||||
if (!match) throw new Error(`${BUNDLE_BUDGET_FILE}: нет export const ${name}`);
|
||||
return Number(match[1].replaceAll('_', ''));
|
||||
}
|
||||
|
||||
const grouped = (value) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_');
|
||||
|
||||
export function rewriteConst(source, name, value) {
|
||||
readConst(source, name);
|
||||
return source.replace(new RegExp(`^export const ${name} = [\\d_]+;$`, 'm'), `export const ${name} = ${grouped(value)};`);
|
||||
}
|
||||
|
||||
/** Состояние одного храповика относительно потолка беты и полосы. */
|
||||
export function ratchetState(fact, ceiling, band) {
|
||||
if (!Number.isFinite(fact) || !Number.isFinite(ceiling)) return 'missing';
|
||||
if (fact > ceiling + band) return 'grew';
|
||||
if (fact > ceiling) return 'over';
|
||||
if (fact < ceiling) return 'loose';
|
||||
return 'tight';
|
||||
}
|
||||
|
||||
export function ratchetRows({ coreFacts = {}, coreCaps = {}, bundleFacts = {}, bundleCeilings = {}, metrics = {}, baseline = {} }) {
|
||||
const rows = [];
|
||||
for (const [file, cap] of Object.entries(coreCaps)) {
|
||||
rows.push({ kind: 'core', name: file, fact: coreFacts[file], ceiling: cap, band: CORE_BAND });
|
||||
}
|
||||
for (const entry of BUNDLE_CEILINGS) {
|
||||
rows.push({ kind: 'bundle', name: entry.label, key: entry.name, fact: bundleFacts[entry.metric], ceiling: bundleCeilings[entry.name], band: entry.band });
|
||||
}
|
||||
for (const name of METRIC_NAMES) {
|
||||
rows.push({ kind: 'monolith', name, fact: metrics[name], ceiling: baseline?.[name], band: METRIC_BANDS[name] ?? 0 });
|
||||
}
|
||||
return rows.map((row) => ({ ...row, state: ratchetState(row.fact, row.ceiling, row.band) }));
|
||||
}
|
||||
|
||||
const STATE_TEXT = {
|
||||
tight: 'равен факту',
|
||||
loose: 'рыхлый — опустить до факта',
|
||||
over: 'факт в полосе над потолком — поднять до факта или вернуть',
|
||||
grew: 'факт выше полосы — гейт ветки обязан был покраснеть',
|
||||
missing: 'не измерен',
|
||||
};
|
||||
|
||||
export function formatRow(row) {
|
||||
const delta = Number.isFinite(row.fact) && Number.isFinite(row.ceiling) ? row.fact - row.ceiling : null;
|
||||
const sign = delta == null ? '' : ` (${delta > 0 ? '+' : ''}${delta}, полоса +${row.band})`;
|
||||
return `${row.kind.padEnd(8)} ${row.name}: факт ${row.fact ?? '—'} · потолок ${row.ceiling ?? '—'}${sign} — ${STATE_TEXT[row.state]}`;
|
||||
}
|
||||
|
||||
function measure(root) {
|
||||
const coreSource = readFileSync(resolve(root, CORE_BUDGET_FILE), 'utf8');
|
||||
const bundleSource = readFileSync(resolve(root, BUNDLE_BUDGET_FILE), 'utf8');
|
||||
const coreCaps = readCoreCaps(coreSource);
|
||||
const coreFacts = Object.fromEntries(Object.keys(coreCaps).map((file) => [
|
||||
file, existsSync(resolve(root, file)) ? readFileSync(resolve(root, file), 'utf8').split('\n').length : undefined,
|
||||
]));
|
||||
const manifestPath = resolve(root, 'dist/houseplan-assets.json');
|
||||
const bundleFacts = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
|
||||
const bundleCeilings = Object.fromEntries(BUNDLE_CEILINGS.map((entry) => [entry.name, readConst(bundleSource, entry.name)]));
|
||||
const { metrics } = collectMetrics(root);
|
||||
const baseline = readBaseline(root) || {};
|
||||
return { coreSource, bundleSource, coreCaps, coreFacts, bundleFacts, bundleCeilings, metrics, baseline };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const [command] = process.argv.slice(2);
|
||||
const root = process.cwd();
|
||||
try {
|
||||
const m = measure(root);
|
||||
const rows = ratchetRows(m);
|
||||
if (command === 'report') {
|
||||
const warn = process.argv.includes('--warn');
|
||||
for (const row of rows) console.log(formatRow(row));
|
||||
const attention = rows.filter((row) => row.state !== 'tight');
|
||||
if (warn) {
|
||||
for (const row of attention) console.log(`::warning::храповик ${row.kind} ${row.name}: ${STATE_TEXT[row.state]} — node scripts/ratchets.mjs tighten`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(rows.some((row) => row.state === 'grew' || row.state === 'missing') ? 1 : 0);
|
||||
} else if (command === 'tighten') {
|
||||
const missing = rows.filter((row) => row.state === 'missing');
|
||||
if (missing.length) throw new Error(`не измерено: ${missing.map((row) => `${row.kind} ${row.name}`).join(', ')} — сначала npm run build`);
|
||||
const coreFacts = Object.fromEntries(rows.filter((row) => row.kind === 'core').map((row) => [row.name, row.fact]));
|
||||
writeFileSync(resolve(root, CORE_BUDGET_FILE), rewriteCoreCaps(m.coreSource, coreFacts));
|
||||
let bundleSource = m.bundleSource;
|
||||
for (const row of rows.filter((r) => r.kind === 'bundle')) bundleSource = rewriteConst(bundleSource, row.key, row.fact);
|
||||
writeFileSync(resolve(root, BUNDLE_BUDGET_FILE), bundleSource);
|
||||
const baseline = Object.fromEntries(METRIC_NAMES.map((name) => [name, m.metrics[name]]));
|
||||
writeFileSync(resolve(root, BASELINE_FILE), `${JSON.stringify(baseline, null, 2)}\n`);
|
||||
for (const row of rows.filter((r) => r.state !== 'tight')) console.log(`${row.kind} ${row.name}: ${row.ceiling} → ${row.fact}`);
|
||||
console.log(`потолки опущены до факта: ${CORE_BUDGET_FILE}, ${BUNDLE_BUDGET_FILE}, ${BASELINE_FILE} — закоммитить вместе с кандидатом беты`);
|
||||
} else {
|
||||
throw new Error('usage: ratchets.mjs report [--warn] | tighten');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(command === 'report' && process.argv.includes('--warn') ? 0 : 1);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
// и не «их», а пересборка по дереву, в котором остановился ребейз.
|
||||
//
|
||||
// Правило одно: остановку разрешает только набор конфликтов, в котором ВСЕ
|
||||
// пути — индекс (или пути, которые вызывающий объявил своими: бандл в
|
||||
// `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и
|
||||
// пути — индекс, данные, где права сторона dev (`UPSTREAM_WINS`, #698), или
|
||||
// пути, которые вызывающий объявил своими (бандл в `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и
|
||||
// перечень ВСЕХ конфликтующих файлов, индекс в нём тоже: автор видит полную
|
||||
// картину, дерево и HEAD как были.
|
||||
//
|
||||
@@ -32,6 +32,15 @@ import { fileURLToPath } from 'node:url';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const REVIEWS_INDEX_PATH = 'docs/reviews/INDEX.md';
|
||||
/**
|
||||
* #698: данные, в которых на конфликте права сторона `dev`. База метрик
|
||||
* монолита — снимок чисел, а не решение задачи: правильное значение для
|
||||
* объединённого дерева не «наше» и не «их», и судит его гейт связности на
|
||||
* Validate кандидата (`lint:unused`; полоса у всех чисел — #699), а не автор
|
||||
* руками. Ченджлоги сюда не входят —
|
||||
* их объединяет `merge=union` в `.gitattributes`, и конфликта не бывает.
|
||||
*/
|
||||
export const UPSTREAM_WINS = Object.freeze(['scripts/monolith-baseline.json']);
|
||||
/** Скрипт индекса — по абсолютному пути: ребейз идёт и из чужого cwd (worktree кандидата). */
|
||||
export const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
|
||||
/** Предохранитель от зацикливания: коммитов в ветке задачи единицы, не тысячи. */
|
||||
@@ -46,18 +55,19 @@ const uniquePaths = (paths) => [...new Set(paths.map((p) => String(p).trim()).fi
|
||||
*
|
||||
* @param {string[]} paths конфликтующие пути (`git diff --name-only --diff-filter=U`)
|
||||
* @param {{ extra?: (path: string) => boolean }} [opts] пути, которые вызывающий решает сам
|
||||
* @returns {{ action: 'resolve', index: boolean, extra: string[], conflicts: string[] }
|
||||
* @returns {{ action: 'resolve', index: boolean, upstream: string[], extra: string[], conflicts: string[] }
|
||||
* | { action: 'abort', reason: 'no-conflicts'|'manual', manual: string[], conflicts: string[] }}
|
||||
*/
|
||||
export function planStop(paths, { extra = () => false } = {}) {
|
||||
const conflicts = uniquePaths(paths);
|
||||
if (!conflicts.length) return { action: 'abort', reason: 'no-conflicts', manual: [], conflicts };
|
||||
const manual = conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !extra(path));
|
||||
const manual = conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !UPSTREAM_WINS.includes(path) && !extra(path));
|
||||
if (manual.length) return { action: 'abort', reason: 'manual', manual, conflicts };
|
||||
return {
|
||||
action: 'resolve',
|
||||
index: conflicts.includes(REVIEWS_INDEX_PATH),
|
||||
extra: conflicts.filter((path) => path !== REVIEWS_INDEX_PATH),
|
||||
upstream: conflicts.filter((path) => UPSTREAM_WINS.includes(path)),
|
||||
extra: conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !UPSTREAM_WINS.includes(path)),
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
@@ -127,6 +137,12 @@ export function rebaseRegenerating({
|
||||
return abort({ reason: plan.reason, conflicts: plan.conflicts, manual: plan.manual, output: step.stderr || step.stdout });
|
||||
}
|
||||
for (const path of plan.extra) resolved.push(`${path} ← ${extra.resolve(path)}`);
|
||||
// На ребейзе `--ours` — сторона, НА которую ребейзят, то есть dev.
|
||||
for (const path of plan.upstream) {
|
||||
git(['checkout', '--ours', '--', path]);
|
||||
git(['add', '--', path]);
|
||||
resolved.push(`${path} ← dev`);
|
||||
}
|
||||
if (plan.index) {
|
||||
// Не --ours и не --theirs: ни одна сторона не знает документов другой.
|
||||
rebuildIndex();
|
||||
|
||||
@@ -513,6 +513,8 @@ if (invokedDirectly) {
|
||||
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
|
||||
}).manifest;
|
||||
const existingRelease = releaseView();
|
||||
// #699: храповики с полосой опускает до факта бета — напоминание, не гейт.
|
||||
run(process.execPath, ['scripts/ratchets.mjs', 'report', '--warn'], { allowFailure: true, inherit: true });
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
|
||||
|
||||
@@ -41,6 +41,26 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
*/
|
||||
export const BROAD_SHARE = 0.2;
|
||||
|
||||
/**
|
||||
* «Визуальный минимум» (#690 п.1′): смоки режимов, слоёв и отрисовки, которые
|
||||
* выдаются, когда дифф исполняемый, а связь ни с одним смоком не доказана.
|
||||
* Раньше такой ответ был только текстом «решает ревьюер», и автор гонял одни
|
||||
* названные в ТЗ смоки: CSS-правка #687 не выбрала `smoke_modes`, и `dev`
|
||||
* остался красным для следующей задачи (#685). Набор фиксирован и короткий —
|
||||
* локально меньше минуты, — чтобы «неопределённость» стоила прогона, а не
|
||||
* полной матрицы: полная матрица остаётся гейтом `dev` и беты.
|
||||
*/
|
||||
export const VISUAL_MINIMUM = Object.freeze([
|
||||
'smoke_modes.mjs', // режимы: иконки плана, панели, выход в вид (#687)
|
||||
'smoke_mode_transition.mjs', // вид ↔ редактор: высота тулбара, сцена, камера
|
||||
'smoke_hide_layers.mjs', // слои плана по флагам
|
||||
'smoke_decor_layer_order.mjs', // порядок слоёв декора
|
||||
'smoke_daycycle_zoom_layers.mjs', // слои дня/ночи при любом масштабе (#689)
|
||||
'smoke_static_zoom_sharpness.mjs', // свежий векторный кадр после зума (#685, #689)
|
||||
'smoke_wall_hatch_density.mjs', // штриховка стен в обоих рендерах
|
||||
'smoke_visual_continuity.mjs', // последний кадр плана не пропадает
|
||||
]);
|
||||
|
||||
/** Файлы, чей дифф способен что-то сломать в браузере. */
|
||||
const isExecutableFrontend = (file) => file.startsWith('src/')
|
||||
&& file.endsWith('.ts') && !file.endsWith('.d.ts');
|
||||
@@ -197,6 +217,8 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {})
|
||||
const registered = registeredSmokes(parsed.symbols)
|
||||
.filter((entry) => !directNames.has(entry.smoke));
|
||||
|
||||
const unproven = parsed.executable.length > 0
|
||||
&& !direct.some((entry) => entry.strong) && !registered.length;
|
||||
return {
|
||||
files: parsed.files,
|
||||
executable: parsed.executable,
|
||||
@@ -211,8 +233,9 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {})
|
||||
// нельзя молчать. `noExecutableDiff` от него отличается: там и правда
|
||||
// нечего проверять (docs, i18n-строки без кода, чистая инфраструктура).
|
||||
noExecutableDiff: parsed.executable.length === 0,
|
||||
unproven: parsed.executable.length > 0
|
||||
&& !direct.some((entry) => entry.strong) && !registered.length,
|
||||
unproven,
|
||||
// #690 п.1′: неопределённость не молчит и не отдаёт пустой набор.
|
||||
visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +304,9 @@ function report(selection) {
|
||||
+ ' доказуемо.');
|
||||
lines.push('Это не значит «смоки не нужны»: значит, что связь не доказана'
|
||||
+ ' и решает ревьюер.');
|
||||
lines.push(`Визуальный минимум (${selection.visualMinimum.length}) — прогнать до S7;`
|
||||
+ ' `npm run gate:small -- --smokes` гоняет его сам (#690):');
|
||||
for (const smoke of selection.visualMinimum) lines.push(` demo/${smoke}`);
|
||||
if (selection.broad.length) {
|
||||
lines.push(`Широкие символы (есть почти везде, ничего не различают):`
|
||||
+ ` ${selection.broad.slice(0, 10).join(', ')}`);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Перестановка статусной метки после интеграции ревью (#706).
|
||||
*
|
||||
* node scripts/status-label.mjs --repo=owner/name --issue=NN --from=<метка> --to=<метка>
|
||||
*
|
||||
* Обычный исход — одна правка: `--add-label TO --remove-label FROM`. Исход
|
||||
* `rereview` (#492) возвращает задачу в ту же метку, из которой она пришла:
|
||||
* `TO == FROM == S7-code-review`. Одним вызовом `gh` такую метку добавляет и
|
||||
* тут же снимает — задача оставалась без статуса, и нового захода не было
|
||||
* (#699, 2026-09-28). Поэтому одинаковая метка снимается и ставится заново
|
||||
* двумя вызовами, как в автосверке (`relabel`, #555): событие `labeled`
|
||||
* запускает новый заход ревью, а сбой повторной постановки роняет шаг, и
|
||||
* конвейер зовёт владельца.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { relabel } from './process-reconcile.mjs';
|
||||
|
||||
function gh(args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync('gh', args, { encoding: 'utf8' });
|
||||
if (!allowFailure && (result.error || result.status !== 0)) {
|
||||
throw new Error(`gh ${args.join(' ')} → ${(result.stderr || result.error?.message || '').trim()}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {'moved' | 'relabeled'} */
|
||||
export function moveStatusLabel({ repo, issue, from = '', to, execute = gh }) {
|
||||
if (!repo || !issue || !to) throw new Error('usage: status-label.mjs --repo=owner/name --issue=NN --from=<метка> --to=<метка>');
|
||||
if (from === to) {
|
||||
relabel(repo, { number: issue }, to, execute);
|
||||
return 'relabeled';
|
||||
}
|
||||
const args = ['issue', 'edit', String(issue), '--repo', repo, '--add-label', to];
|
||||
if (from) args.push('--remove-label', from);
|
||||
execute(args);
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
||||
try {
|
||||
const how = moveStatusLabel({ repo: arg('repo'), issue: arg('issue'), from: arg('from'), to: arg('to') });
|
||||
console.log(`${arg('from') || '(без метки)'} -> ${arg('to')}${how === 'relabeled' ? ' (снята и поставлена заново — новый заход, #706)' : ''}`);
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -135,15 +135,16 @@ export { hasTrackLabel, trackFromLabels };
|
||||
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
|
||||
* признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по
|
||||
* себе признаком не являются: их носит и инфраструктурная задача после ревью.
|
||||
* Метка `trivial` — признак сама по себе (r1 #632): короткий трек (PROCESS §5.1)
|
||||
* идёт S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у
|
||||
* него нет. Ускоренный инфраструктурный вход понятия трека не имеет, поэтому
|
||||
* `trivial` на нём не бывает; `infra` рядом с ней — тематическая метка.
|
||||
* Прежняя метка `trivial` — признак сама по себе (r1 #632): такие задачи шли
|
||||
* S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у них
|
||||
* нет. С #695 `trivial` читается как `track:show` (PROCESS §5.1), новым задачам
|
||||
* не ставится, но на старых остаётся. Инфраструктурный вход её не несёт; `infra`
|
||||
* рядом с ней — тематическая метка.
|
||||
*/
|
||||
export function productFlowEvidence({ status = null, labels = [], issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) {
|
||||
const reasons = [];
|
||||
if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`);
|
||||
if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)');
|
||||
if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)');
|
||||
if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue');
|
||||
if (specs.length) reasons.push('файл ТЗ в docs/specs');
|
||||
if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ');
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
* который компилятор доказал мёртвым.
|
||||
*
|
||||
* 2. Храповик: шесть чисел `scripts/monolith-metrics.mjs` не растут
|
||||
* относительно `scripts/monolith-baseline.json` (`bundleBytes` — с полосой
|
||||
* ±2 000 Б, как gzip-потолок #438; остальные — точно). Снижение — не ошибка, но
|
||||
* база обязана быть опущена тем же коммитом (`--update`): незафиксированный
|
||||
* выигрыш монолит отыграет обратно первой же правкой. Рост допускается
|
||||
* только с явной записью в issue задачи и правкой базы в том же коммите —
|
||||
* гейт печатает, какое число и на сколько.
|
||||
* относительно `scripts/monolith-baseline.json` больше своей полосы
|
||||
* (`METRIC_BANDS`, #699). Снижение задачу не красит: базу до факта опускает
|
||||
* бета (`node scripts/ratchets.mjs tighten`), иначе выигрыш монолит отыграет
|
||||
* обратно. Рост сверх полосы допускается только с явной записью в issue
|
||||
* задачи и правкой базы в том же коммите — гейт печатает, какое число и на
|
||||
* сколько.
|
||||
*
|
||||
* `bundleBytes` требует собранного `dist/`: гейт стоит после `npm run build`
|
||||
* (в `gate:small` и в job `frontend` Validate). Без сборки число не судится,
|
||||
@@ -31,7 +31,7 @@ import { writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import {
|
||||
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline,
|
||||
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline, METRIC_BANDS,
|
||||
} from './monolith-metrics.mjs';
|
||||
|
||||
/**
|
||||
@@ -60,15 +60,16 @@ export function decide({ metrics, violations, baseline }) {
|
||||
fail = true;
|
||||
for (const g of grown) {
|
||||
lines.push(`FAIL связность выросла: ${g.name} ${g.base ?? 'нет в базе'} → ${g.now}`
|
||||
+ ' — вернуть или обосновать в issue и поднять базу тем же коммитом');
|
||||
+ ` — больше полосы ${METRIC_BANDS[g.name] ?? 0}; вернуть или обосновать в issue и поднять базу тем же коммитом`);
|
||||
}
|
||||
}
|
||||
if (shrunk.length) {
|
||||
// Снижение без записи в базу — тоже отказ: храповик работает в обе стороны.
|
||||
fail = true;
|
||||
for (const s of shrunk) lines.push(`FAIL связность упала, база не опущена: ${s.name} ${s.base} → ${s.now} — node scripts/unused-locals-gate.mjs --update`);
|
||||
// #699: снижение задачу не красит — базу до факта опускает бета
|
||||
// (`node scripts/ratchets.mjs tighten`). Храповик по-прежнему двусторонний,
|
||||
// но вторая сторона живёт на бете, а не в каждой ветке.
|
||||
for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);
|
||||
}
|
||||
if (!grown.length && !shrunk.length) lines.push('ok все числа равны базе');
|
||||
if (!grown.length && !shrunk.length) lines.push('ok все числа в полосе базы');
|
||||
if (metrics.bundleBytes == null) {
|
||||
fail = true;
|
||||
lines.push('FAIL dist/ не собран — bundleBytes не судится; сначала npm run build');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { basename } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bundleCommitErrors } from './bundle-policy.mjs';
|
||||
import { classify } from './change-classes.mjs';
|
||||
|
||||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||||
export const ENFORCEMENT_BOUNDARY = '8e2973fa7a7cb1a80204ff95ecf3f2d7c36ed2ce';
|
||||
@@ -52,15 +53,27 @@ export function terminalTrailers(message) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* #701 (PROCESS.md §3 п.10): документационный коммит — только файлы класса C —
|
||||
* трейлеров не требует, как `skip issue` у CPython. Правило №1 охраняет
|
||||
* продуктовый код, а не опечатку в гайде. Пустой список файлов (сообщение без
|
||||
* `--staged`) — не документационный коммит: судить нечем, правило прежнее.
|
||||
* Трейлеры, если они есть, судятся всегда.
|
||||
*/
|
||||
export function isDocsOnlyCommit(changedFiles = []) {
|
||||
return changedFiles.length > 0 && changedFiles.every((file) => classify(file.replaceAll('\\', '/')) === 'C');
|
||||
}
|
||||
|
||||
export function validateCommitMessage(message, changedFiles = [], { baselineIndex = undefined, authorDate = null } = {}) {
|
||||
const trailers = terminalTrailers(message);
|
||||
const errors = [];
|
||||
const issues = trailers.get('Issue') || [];
|
||||
if (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value))) {
|
||||
const visible = trailers.get('User-Visible') || [];
|
||||
const exempt = isDocsOnlyCommit(changedFiles) && !issues.length && !visible.length;
|
||||
if (!exempt && (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value)))) {
|
||||
errors.push("missing or invalid terminal 'Issue: #<positive number>' trailer");
|
||||
}
|
||||
const visible = trailers.get('User-Visible') || [];
|
||||
if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) {
|
||||
if (!exempt && (visible.length !== 1 || !/^(yes|no)$/.test(visible[0]))) {
|
||||
errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer");
|
||||
}
|
||||
const normalizedFiles = changedFiles.map((file) => file.replaceAll('\\', '/'));
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export class StairViewRuntime {
|
||||
|| !stair.target_space_id) return;
|
||||
this.owner._tabClick(stair.target_space_id);
|
||||
};
|
||||
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
|
||||
return svg`<g class="hp-stair hp-stair-view ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
style=${stairStyleVars(
|
||||
|
||||
@@ -1636,6 +1636,9 @@ export const planStyles = css`
|
||||
.dthandle, .dtknob, dt-* cursors) painted in the top overlay; the draft
|
||||
being drawn is inert until it is placed */
|
||||
.hp-stair.draft .hp-stair-hit { pointer-events: none; }
|
||||
.hp-stair.input-enabled .hp-stair-hit { cursor: move; }
|
||||
/* #693: the move cursor belongs to the plan editor. The View layer sets
|
||||
input-enabled only to receive clicks; its hit area keeps the group's
|
||||
cursor: pointer on a link, the stage's otherwise. */
|
||||
.hp-stair.input-enabled:not(.hp-stair-view) .hp-stair-hit { cursor: move; }
|
||||
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
|
||||
`;
|
||||
|
||||
+35
-40
@@ -879,28 +879,25 @@ test('#438 поставляемый граф лежит внутри полос
|
||||
assert.equal(violation, null, violation?.text);
|
||||
});
|
||||
|
||||
test('#438 рост выше потолка — отказ с числом и с указанием, что делать', () => {
|
||||
const grew = initialViewCeilingViolation(292_400, { ceiling: 292_000, band: 2_000 });
|
||||
test('#438/#699 рост выше полосы над потолком беты — отказ с числом и с указанием, что делать', () => {
|
||||
const grew = initialViewCeilingViolation(294_400, { ceiling: 292_000, band: 2_000 });
|
||||
assert.equal(grew.kind, 'grew');
|
||||
assert.equal(grew.over, 400);
|
||||
assert.match(grew.text, /выше потолка 292000 B на 400 B/);
|
||||
assert.equal(grew.over, 2_400);
|
||||
assert.match(grew.text, /выше потолка беты 292000 B на 2400 B — больше полосы 2000 B/);
|
||||
assert.match(grew.text, /Поднимите потолок в этом же коммите/);
|
||||
assert.match(grew.text, /#367/, 'у отказа обязан быть выход, а не только запрет');
|
||||
// Ровно на потолке — ещё не рост: граница включительная, иначе гейт краснеет
|
||||
// на равенстве и разбираться идут не с графом, а с гейтом.
|
||||
assert.equal(initialViewCeilingViolation(292_000, { ceiling: 292_000 }), null);
|
||||
// Ровно потолок плюс полоса — ещё не рост: граница включительная.
|
||||
assert.equal(initialViewCeilingViolation(294_000, { ceiling: 292_000, band: 2_000 }), null);
|
||||
assert.equal(initialViewCeilingViolation(292_400, { ceiling: 292_000, band: 2_000 }), null, 'рост в полосе задачу не красит');
|
||||
});
|
||||
|
||||
test('#438 падение ниже полосы требует опустить потолок', () => {
|
||||
// Вторая половина храповика, без которой он не храповик: выигрыш, который не
|
||||
// зафиксировали, отыгрывается обратно молча. Так запас бюджета ушёл с 26 КБ
|
||||
// до 8.3 КБ за сутки — каждая отдельная строка выглядела нормально.
|
||||
const shrank = initialViewCeilingViolation(289_500, { ceiling: 292_000, band: 2_000 });
|
||||
assert.equal(shrank.kind, 'shrank');
|
||||
assert.equal(shrank.under, 2_500);
|
||||
assert.match(shrank.text, /Опустите потолок/);
|
||||
assert.equal(initialViewCeilingViolation(290_000, { ceiling: 292_000, band: 2_000 }), null,
|
||||
'нижняя граница полосы тоже включительная');
|
||||
test('#699 падение ниже потолка задачу не красит — потолок опускает бета', () => {
|
||||
// Вторая половина храповика не исчезла, а переехала на бету:
|
||||
// `node scripts/ratchets.mjs tighten` опускает потолок до факта кандидата.
|
||||
assert.equal(initialViewCeilingViolation(289_500, { ceiling: 292_000, band: 2_000 }), null);
|
||||
assert.equal(initialViewCeilingViolation(100_000, { ceiling: 292_000, band: 2_000 }), null);
|
||||
assert.equal(lazyGraphCeilingViolation(10, { ceiling: 239_000, label: 'lazy editor graph' }), null);
|
||||
assert.equal(lazyGraphCeilingViolation(241_001, { ceiling: 239_000, label: 'lazy editor graph' }).kind, 'grew');
|
||||
assert.equal(initialViewCeilingViolation(NaN).kind, 'missing');
|
||||
assert.equal(initialViewCeilingViolation(undefined).kind, 'missing');
|
||||
});
|
||||
@@ -1030,15 +1027,15 @@ test('#438 CLI действительно применяет потолок, а
|
||||
assert.equal(inside.status, 0, inside.output);
|
||||
// #627 AC1: the onboarding graph is printed with its ceiling and band.
|
||||
assert.match(inside.output, new RegExp(`lazy onboarding: ${LAZY_ONBOARDING_GZIP_CEILING - 1_000} B gzip`
|
||||
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND}\\)`));
|
||||
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B \\+${LAZY_GRAPH_CEILING_BAND}\\)`));
|
||||
|
||||
const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + 1);
|
||||
// #699: полоса — над потолком беты; выше неё CLI называет храповик, ниже
|
||||
// потолка задача не краснеет.
|
||||
const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + INITIAL_VIEW_CEILING_BAND + 1);
|
||||
assert.equal(grew.status, 1, grew.output);
|
||||
assert.match(grew.output, /выше потолка/);
|
||||
|
||||
assert.match(grew.output, /выше потолка беты .* больше полосы/);
|
||||
const shrank = runBudgetCli(INITIAL_VIEW_GZIP_CEILING - INITIAL_VIEW_CEILING_BAND - 1);
|
||||
assert.equal(shrank.status, 1, shrank.output);
|
||||
assert.match(shrank.output, /Опустите потолок/);
|
||||
assert.equal(shrank.status, 0, shrank.output);
|
||||
|
||||
// И общий бюджет остаётся внешней стеной: он выше потолка, значит красным
|
||||
// становится потолок, а не бюджет — но и бюджет обязан уметь падать.
|
||||
@@ -1064,10 +1061,8 @@ test('#593 потолки ленивых графов — гейт, а не ст
|
||||
] : []) {
|
||||
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
|
||||
assert.equal(violation, null, violation?.text);
|
||||
// Факт лежит не у края полосы — с тем же запасом, что у стартового графа.
|
||||
assert.ok(ceiling - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
|
||||
assert.ok(bytes - (ceiling - LAZY_GRAPH_CEILING_BAND) > 500,
|
||||
`${label}: снизу меньше 500 Б — гейт потребует опустить потолок из-за шума`);
|
||||
// #699: запас сверху — полоса над потолком беты; снизу границы у задачи нет.
|
||||
assert.ok(ceiling + LAZY_GRAPH_CEILING_BAND - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
|
||||
}
|
||||
// Гейт обязан быть исполняемым и на синтетике, обе стороны — и это не
|
||||
// зависит от свежести бандла: мутант «потолок никогда не срабатывает»
|
||||
@@ -1075,13 +1070,12 @@ test('#593 потолки ленивых графов — гейт, а не ст
|
||||
const grew = lazyGraphCeilingViolation(20_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
|
||||
assert.equal(grew.kind, 'grew');
|
||||
assert.equal(grew.over, 2_100);
|
||||
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка 17900 B на 2100 B/);
|
||||
const shrank = lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
|
||||
assert.equal(shrank.kind, 'shrank');
|
||||
assert.match(shrank.text, /Опустите потолок/);
|
||||
// Границы полосы включительные — иначе гейт краснеет на равенстве.
|
||||
assert.equal(lazyGraphCeilingViolation(17_900, { ceiling: 17_900, label: 'x' }), null);
|
||||
assert.equal(lazyGraphCeilingViolation(15_900, { ceiling: 17_900, label: 'x' }), null);
|
||||
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка беты 17900 B на 2100 B/);
|
||||
// #699: ниже потолка — не находка ветки, потолок опускает бета.
|
||||
assert.equal(lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' }), null);
|
||||
// Граница полосы включительная — иначе гейт краснеет на равенстве.
|
||||
assert.equal(lazyGraphCeilingViolation(19_900, { ceiling: 17_900, label: 'x' }), null);
|
||||
assert.equal(lazyGraphCeilingViolation(19_901, { ceiling: 17_900, label: 'x' }).kind, 'grew');
|
||||
assert.equal(lazyGraphCeilingViolation(NaN, { ceiling: 17_900, label: 'x' }).kind, 'missing');
|
||||
// Текст обязан называть граф: «graph выше потолка» не говорит, куда смотреть.
|
||||
assert.match(lazyGraphCeilingViolation(NaN, { ceiling: 1, label: 'lazy editor graph' }).text,
|
||||
@@ -1096,14 +1090,15 @@ test('#627 AC1 граф онбординга гейтится тем же пот
|
||||
const bytes = manifest.lazyOnboardingGzipBytes;
|
||||
assert.ok(Number.isFinite(bytes) && bytes > 0, 'манифест обязан измерять граф онбординга');
|
||||
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes)));
|
||||
const over = bytes - LAZY_GRAPH_CEILING_BAND - 1;
|
||||
assert.throws(
|
||||
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - 1)),
|
||||
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка ${bytes - 1} B на 1 B`),
|
||||
);
|
||||
assert.throws(
|
||||
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
|
||||
/lazy onboarding graph \d+ B gzip ниже потолка .*Опустите потолок/,
|
||||
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(over)),
|
||||
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка беты ${over} B на ${bytes - over} B`),
|
||||
);
|
||||
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - LAZY_GRAPH_CEILING_BAND)),
|
||||
'#699: рост в полосе над потолком беты задачу не красит');
|
||||
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
|
||||
'#699: ниже потолка — не находка ветки, потолок опускает бета');
|
||||
// Потолок по умолчанию — поставляемый, и поставляемый граф в его полосе —
|
||||
// пока бандл свежий (#657); синтетика выше от свежести не зависит.
|
||||
if (!shippedBundleIsFresh(manifest)) { t.diagnostic(STALE_SHIPPED_BUNDLE); return; }
|
||||
|
||||
@@ -215,7 +215,7 @@ test('#586: preflight спрашивает режим одним значени
|
||||
assert.match(workflow, /classify-changes\.mjs --screenshots-mode/);
|
||||
assert.ok(!/=\s*"heavy=true"/.test(workflow),
|
||||
'сравнение со строкой «heavy=true» вернулось — строгий режим снова не включится');
|
||||
assert.match(workflow, /check-docs\.mjs --external --screenshots=\$mode/);
|
||||
assert.match(workflow, /check-docs\.mjs "\$external" --screenshots=\$mode/);
|
||||
});
|
||||
|
||||
test('#510 AC1 / #601 AC1: мутанты по диффу запрашиваются только кнопкой mutants=true и PR — не пушем, не кандидатом беты, не full', () => {
|
||||
|
||||
@@ -105,3 +105,19 @@ test('the audited beta.2 baseline exception is exact and golden-only', () => {
|
||||
assert.equal(validateHistoricalCommit(`${audited.slice(0, -1)}2`, message, changed).length, 2);
|
||||
assert.match(validateHistoricalCommit(audited, 'Update baseline', changed)[0], /Issue/);
|
||||
});
|
||||
|
||||
// #701 (PROCESS.md §3 п.10): трейлеры — только на коммитах с продуктовыми и
|
||||
// инфраструктурными файлами; документационный коммит их не требует.
|
||||
test('#701: документационный коммит (только класс C) трейлеров не требует', () => {
|
||||
assert.deepEqual(validateCommitMessage('docs: fix a typo in the guide', ['docs/USER-GUIDE.md', 'README.md']), []);
|
||||
assert.deepEqual(validateCommitMessage('docs: changelog wording', ['docs/CHANGELOG.md']), []);
|
||||
// Хоть один файл вне класса C — прежнее правило.
|
||||
assert.equal(validateCommitMessage('fix: x', ['docs/USER-GUIDE.md', 'src/card.ts']).length, 2);
|
||||
assert.equal(validateCommitMessage('test: x', ['test/a.test.mjs']).length, 2);
|
||||
assert.equal(validateCommitMessage('build: x', ['dist/houseplan-card.js']).length >= 2, true);
|
||||
// Судить нечем — не документационный коммит.
|
||||
assert.equal(validateCommitMessage('docs: typo', []).length, 2);
|
||||
// Трейлер, если он есть, судится всегда: кривой номер — ошибка и в docs-коммите.
|
||||
assert.equal(validateCommitMessage('docs: typo\n\nIssue: #x', ['docs/a.md']).length, 2);
|
||||
assert.deepEqual(validateCommitMessage('docs: typo\n\nIssue: #9\nUser-Visible: no', ['docs/a.md']), []);
|
||||
});
|
||||
|
||||
@@ -14,10 +14,19 @@ import { readFileSync } from 'node:fs';
|
||||
// Мера — `split('\n').length`, то есть строки плюс завершающий перевод.
|
||||
// Та же функция и для потолков, и для измерения: две разные меры разошлись бы
|
||||
// на единицу, и гейт краснел бы на пустом месте (проверено при написании).
|
||||
const SLACK = 250;
|
||||
//
|
||||
// #699 (решение владельца 2026-09-28): полоса вместо точки. Потолок —
|
||||
// факт на последней бете; задача может вырасти над ним не больше чем на
|
||||
// CORE_BAND строк, и уменьшение её не красит. Потолки опускает до факта
|
||||
// релиз-менеджер раз в бету (`node scripts/ratchets.mjs tighten`): пока их
|
||||
// правила каждая задача, два параллельных ядра конфликтовали на этих числах, а
|
||||
// задача упиралась в потолок, потому что перед ней влили чужую (#689 после #691).
|
||||
export const CORE_BAND = 50;
|
||||
|
||||
// Потолки. Меняются только вручную и только вместе с объяснением в ревью:
|
||||
// потолок, который вычисляется от текущего размера, потолком не является.
|
||||
// С #699 число — факт последней беты: его ставит `ratchets.mjs tighten` на
|
||||
// кандидате, а строки ниже — история решений, а не расчёт текущего числа.
|
||||
const CAPS = {
|
||||
// #485 adds the View-side subscription/render integration seams; the live
|
||||
// model and rendering themselves remain in dedicated modules.
|
||||
@@ -59,13 +68,14 @@ const CAPS = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Храповик: наверх не пускает, вниз — требует зафиксировать выигрыш.
|
||||
* Храповик с полосой (#699): выше `cap + band` не пускает; выигрыш фиксирует
|
||||
* бета, а не задача.
|
||||
*
|
||||
* Вторая половина важнее первой. Без неё вынос двух тысяч строк ничего не
|
||||
* изменит: потолок останется прежним, и через полгода ядро дорастёт до него
|
||||
* обратно — молча и «в рамках бюджета».
|
||||
* Вторая половина храповика не исчезла, а переехала: без неё вынос двух тысяч
|
||||
* строк ничего не изменит — потолок останется прежним, и ядро дорастёт до него
|
||||
* обратно молча. Поэтому `tighten` на каждой бете опускает потолок до факта.
|
||||
*/
|
||||
export function coreBudgetViolations(sizes, caps, slack = SLACK) {
|
||||
export function coreBudgetViolations(sizes, caps, band = CORE_BAND) {
|
||||
const problems = [];
|
||||
for (const [file, cap] of Object.entries(caps)) {
|
||||
const lines = sizes[file];
|
||||
@@ -73,19 +83,13 @@ export function coreBudgetViolations(sizes, caps, slack = SLACK) {
|
||||
problems.push({ file, kind: 'missing', text: `${file}: файл не измерен` });
|
||||
continue;
|
||||
}
|
||||
if (lines > cap) {
|
||||
if (lines > cap + band) {
|
||||
problems.push({
|
||||
file, kind: 'grew', over: lines - cap,
|
||||
text: `${file}: ${lines} строк при потолке ${cap} — выросло на ${lines - cap}.`
|
||||
text: `${file}: ${lines} строк при потолке беты ${cap} — выросло на ${lines - cap}, больше полосы ${band}.`
|
||||
+ ' Вынесите столько же в отдельный модуль либо поднимите потолок'
|
||||
+ ' отдельным решением, объяснив его в ревью.',
|
||||
});
|
||||
} else if (lines < cap - slack) {
|
||||
problems.push({
|
||||
file, kind: 'shrank', under: cap - lines,
|
||||
text: `${file}: ${lines} строк при потолке ${cap} — на ${cap - lines} меньше.`
|
||||
+ ' Опустите потолок: незафиксированный выигрыш ядро отыграет обратно.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
@@ -93,37 +97,33 @@ export function coreBudgetViolations(sizes, caps, slack = SLACK) {
|
||||
|
||||
const measure = (file) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').split('\n').length;
|
||||
|
||||
test('ядра не выросли выше потолка и не опустились ниже него молча', () => {
|
||||
test('ядра не выросли выше потолка беты больше чем на полосу (#699)', () => {
|
||||
const sizes = Object.fromEntries(Object.keys(CAPS).map((file) => [file, measure(file)]));
|
||||
const problems = coreBudgetViolations(sizes, CAPS);
|
||||
assert.deepEqual(problems.map((p) => p.text), [], problems.map((p) => p.text).join('\n'));
|
||||
});
|
||||
|
||||
test('рост выше потолка становится нарушением с числом', () => {
|
||||
test('рост выше полосы становится нарушением с числом', () => {
|
||||
const [problem] = coreBudgetViolations({ 'a.ts': 1300 }, { 'a.ts': 1000 });
|
||||
assert.equal(problem.kind, 'grew');
|
||||
assert.equal(problem.over, 300);
|
||||
assert.match(problem.text, /выросло на 300/);
|
||||
assert.match(problem.text, /выросло на 300, больше полосы 50/);
|
||||
});
|
||||
|
||||
test('заметное уменьшение требует опустить потолок', () => {
|
||||
const [problem] = coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 });
|
||||
assert.equal(problem.kind, 'shrank');
|
||||
assert.equal(problem.under, 300);
|
||||
assert.match(problem.text, /Опустите потолок/);
|
||||
test('#699 уменьшение задачу не красит — потолок опускает бета', () => {
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 }), []);
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1 }, { 'a.ts': 1000 }), []);
|
||||
});
|
||||
|
||||
test('изменение в пределах люфта не трогает никого', () => {
|
||||
test('#699 рост в пределах полосы задачу не красит', () => {
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []);
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 800 }, { 'a.ts': 1000 }), []);
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 751 }, { 'a.ts': 1000 }), []);
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1049 }, { 'a.ts': 1000 }), []);
|
||||
});
|
||||
|
||||
test('границы включительно: ровно потолок и ровно люфт нарушением не считаются', () => {
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []);
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 750 }, { 'a.ts': 1000 }), []);
|
||||
assert.equal(coreBudgetViolations({ 'a.ts': 1001 }, { 'a.ts': 1000 })[0].kind, 'grew');
|
||||
assert.equal(coreBudgetViolations({ 'a.ts': 749 }, { 'a.ts': 1000 })[0].kind, 'shrank');
|
||||
test('#699 границы включительно: ровно потолок плюс полоса нарушением не считается', () => {
|
||||
assert.equal(CORE_BAND, 50, 'полоса ядра — решение владельца 2026-09-28');
|
||||
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1050 }, { 'a.ts': 1000 }), []);
|
||||
assert.equal(coreBudgetViolations({ 'a.ts': 1051 }, { 'a.ts': 1000 })[0].kind, 'grew');
|
||||
});
|
||||
|
||||
test('потолки заданы для двух ядер и ни для чего больше', () => {
|
||||
|
||||
@@ -30,8 +30,14 @@ test('check-docs: только две проверки свежести идут
|
||||
assert.ok(viaMode[0].includes('fingerprint is stale'));
|
||||
assert.ok(viaMode[1].includes('capture script changed'));
|
||||
// Хеш картинки, полнота набора сцен и ссылки не имеют права ослабляться.
|
||||
for (const always of ['image hash does not match manifest', 'scenario set is incomplete', 'external link returned']) {
|
||||
for (const always of ['image hash does not match manifest', 'scenario set is incomplete']) {
|
||||
const line = source.split('\n').find((l) => l.includes(always));
|
||||
assert.ok(line && line.includes('errors.push'), `${always} остаётся ошибкой в обоих режимах`);
|
||||
}
|
||||
// #700: внешние ссылки не зависят от режима скриншотов; предупреждением их
|
||||
// делает только явный `--external=warn` на ветке задачи.
|
||||
const external = source.split('\n').find((l) => l.includes('external link returned'));
|
||||
assert.ok(external && external.includes('externalErrors.push'), 'внешняя ссылка идёт в свой сток');
|
||||
assert.match(source, /const externalErrors = EXTERNAL_WARN \? warnings : errors;/);
|
||||
assert.doesNotMatch(source, /externalErrors = freshness/);
|
||||
});
|
||||
|
||||
@@ -44,3 +44,13 @@ test('#634 entry-cost: AGENTS.md называет те же маршруты в
|
||||
assert.deepEqual(route('reviewer'), ROUTES.reviewer.files);
|
||||
assert.deepEqual(route('changing the pipeline'), ROUTES.canon.files);
|
||||
});
|
||||
|
||||
test('#701 D14: промпт ревьюера читает маршрут reviewer в том же порядке, что AGENTS.md', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { ROUTES } = await import('../scripts/entry-cost.mjs');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const start = workflow.indexOf('Прочитай в этом порядке, прежде чем судить:');
|
||||
assert.ok(start > 0, 'нумерованный порядок чтения в промпте найден');
|
||||
const items = [...workflow.slice(start, start + 3000).matchAll(/^\s+(\d)\. (\S+)/gm)].slice(0, 3).map((m) => m[2]);
|
||||
assert.deepEqual(items, ROUTES.reviewer.files);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ test('каждый исход, меняющий метку, объясняетс
|
||||
* dev по порядку (следующая после каждого отклонённого lease), ответы
|
||||
* Validate — по порядку кандидатов.
|
||||
*/
|
||||
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false }) {
|
||||
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false, deleteOk = true }) {
|
||||
const calls = [];
|
||||
let devIndex = 0;
|
||||
let validateIndex = 0;
|
||||
@@ -123,6 +123,7 @@ function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseReject
|
||||
return { result, url: `https://run/${sha}` };
|
||||
},
|
||||
comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); },
|
||||
deleteBranch: (ref, expected) => { calls.push(['delete', ref, expected]); return deleteOk; },
|
||||
log: () => {},
|
||||
};
|
||||
}
|
||||
@@ -642,3 +643,35 @@ test('#696: трек ask по-прежнему диспатчит Validate с м
|
||||
['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
test('#702: после fast-forward ветка задачи удаляется с lease на влитую вершину', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', indexStale: true });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.merged, true);
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'delete'), [['delete', 'issue/1-x', 'mat']],
|
||||
'вершина ветки — материал: индекс-коммит живёт только в dev');
|
||||
assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /ветка `issue\/1-x` удалена/);
|
||||
});
|
||||
|
||||
test('#702: после слияния кандидата удаляется ветка с его вершиной; неудачное слияние ветку не трогает', async () => {
|
||||
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.action, 'push');
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'delete'), [['delete', 'issue/1-x', 'cand-mat-on-dev1']]);
|
||||
const red = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['failed'] });
|
||||
await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: red });
|
||||
assert.deepEqual(red.calls.filter((c) => c[0] === 'delete'), [], 'красный Validate — ветка остаётся автору');
|
||||
const stale = fakeOps({ devTips: ['dev0'], branchTip: 'other', material: 'mat' });
|
||||
stale.revParse = (ref) => (ref === 'origin/dev' ? 'dev0' : ref.startsWith('origin/issue') ? 'other' : ref.endsWith('^') ? 'foreign' : ref);
|
||||
const rejected = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: stale });
|
||||
assert.equal(rejected.action, 'reject-stale');
|
||||
assert.deepEqual(stale.calls.filter((c) => c[0] === 'delete'), [], '#312 — ветку не трогаем');
|
||||
});
|
||||
|
||||
test('#702: сдвинутая вершина — ветка остаётся, слияние в силе', async () => {
|
||||
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', deleteOk: false });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.equal(r.merged, true);
|
||||
assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /оставлена: её вершина сдвинулась после слияния/);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
|
||||
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_BANDS, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
|
||||
harnessMemberNames, hostReferences, portMemberNames, readBaseline,
|
||||
} from '../scripts/monolith-metrics.mjs';
|
||||
import { baselineFrom, decide } from '../scripts/unused-locals-gate.mjs';
|
||||
@@ -109,15 +109,19 @@ test('#624 байты dist — сумма всех файлов, включая
|
||||
}
|
||||
});
|
||||
|
||||
test('#624 храповик: рост на 1 любого числа — красный, снижение без записи базы — тоже, равенство — зелёный', () => {
|
||||
test('#699 храповик: рост сверх полосы любого числа — красный, в полосе и снижение — зелёные', () => {
|
||||
const base = { delegates: 10, portMembers: 20, hostRefs: 30, portPrivates: 4, harnessPrivates: 5, bundleBytes: 1000 };
|
||||
assert.deepEqual(compareWithBaseline({ ...base }, base), { grown: [], shrunk: [] });
|
||||
assert.deepEqual(METRIC_BANDS, { delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND });
|
||||
for (const name of METRIC_NAMES.filter((n) => n !== 'bundleBytes')) {
|
||||
const grown = compareWithBaseline({ ...base, [name]: base[name] + 1 }, base).grown;
|
||||
assert.deepEqual(grown.map((g) => g.name), [name], `${name} +1 виден как рост`);
|
||||
const decision = decide({ metrics: { ...base, [name]: base[name] + 1 }, violations: [], baseline: base });
|
||||
assert.equal(decision.fail, true, `${name} +1 — красный гейт`);
|
||||
const band = METRIC_BANDS[name];
|
||||
assert.deepEqual(compareWithBaseline({ ...base, [name]: base[name] + band }, base).grown, [], `${name} +${band} — в полосе`);
|
||||
const grown = compareWithBaseline({ ...base, [name]: base[name] + band + 1 }, base).grown;
|
||||
assert.deepEqual(grown.map((g) => g.name), [name], `${name} сверх полосы виден как рост`);
|
||||
const decision = decide({ metrics: { ...base, [name]: base[name] + band + 1 }, violations: [], baseline: base });
|
||||
assert.equal(decision.fail, true, `${name} сверх полосы — красный гейт`);
|
||||
assert.match(decision.lines.join('\n'), new RegExp(`связность выросла: ${name} `));
|
||||
assert.equal(decide({ metrics: { ...base, [name]: base[name] + band }, violations: [], baseline: base }).fail, false);
|
||||
}
|
||||
// bundleBytes — с полосой, как gzip-потолок #438: чужой коммит в dev меняет
|
||||
// dist на сотни байт, и точное число красило бы каждую ветку после ребейза.
|
||||
@@ -125,9 +129,10 @@ test('#624 храповик: рост на 1 любого числа — кра
|
||||
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, base).grown.map((g) => g.name), ['bundleBytes']);
|
||||
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes - BUNDLE_BYTES_BAND - 1 }, base).shrunk.map((g) => g.name), ['bundleBytes']);
|
||||
assert.equal(decide({ metrics: { ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, violations: [], baseline: base }).fail, true);
|
||||
// #699: снижение задачу не красит — базу опускает бета.
|
||||
const shrunk = decide({ metrics: { ...base, delegates: 9 }, violations: [], baseline: base });
|
||||
assert.equal(shrunk.fail, true);
|
||||
assert.match(shrunk.lines.join('\n'), /база не опущена: delegates 10 → 9/);
|
||||
assert.equal(shrunk.fail, false);
|
||||
assert.match(shrunk.lines.join('\n'), /ниже базы: delegates 10 → 9 — базу опустит бета/);
|
||||
assert.equal(decide({ metrics: base, violations: [], baseline: base }).fail, false);
|
||||
// Отсутствие числа в базе — рост (нельзя обнулить метрику, удалив ключ).
|
||||
const { portPrivates, ...withoutOne } = base;
|
||||
@@ -158,6 +163,6 @@ test('#624 живое дерево: база равна текущим числ
|
||||
// фактический свежий dist отдельно проверяют bundle-budget и lint:unused.
|
||||
const source = (list) => list.filter((s) => s.name !== 'bundleBytes');
|
||||
assert.deepEqual(source(grown), [], 'связность выросла — вернуть или обосновать');
|
||||
assert.deepEqual(source(shrunk), [], 'связность упала — опустить базу: node scripts/unused-locals-gate.mjs --update');
|
||||
void shrunk; // #699: ниже базы — не ошибка ветки, базу опускает бета (ratchets.mjs tighten)
|
||||
assert.equal(typeof readFileSync(join(root, BASELINE_FILE), 'utf8'), 'string');
|
||||
});
|
||||
|
||||
@@ -206,12 +206,15 @@ test('#332: каждый гвард реестра классифицирует
|
||||
assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`);
|
||||
});
|
||||
|
||||
test('#659: browser guard inventory is reviewed, capped and exact', () => {
|
||||
test('#659/#699: browser guard inventory is reviewed and exact; the limit is a guideline', () => {
|
||||
const markdown = readFileSync(join(repoRoot, 'docs/testing-notes/mutation-browser-guards.md'), 'utf8');
|
||||
const policy = browserGuardPolicy(MUTANTS, documentedBrowserGuards(markdown));
|
||||
assert.equal(policy.count, BROWSER_GUARD_LIMIT);
|
||||
assert.ok(policy.count > 0);
|
||||
assert.deepEqual(policy.missingReasons, []);
|
||||
assert.deepEqual(policy.staleReasons, []);
|
||||
assert.equal(BROWSER_GUARD_LIMIT, 200, 'ориентир остаётся числом в выводе --check');
|
||||
const over = browserGuardPolicy([...MUTANTS, ...Array.from({ length: BROWSER_GUARD_LIMIT + 1 }, (_, i) => ({ id: `x-${i}`, guard: 'node demo/smoke_x.mjs' }))], new Set());
|
||||
assert.equal(over.overLimit, true, 'сверх ориентира policy это видит — --check предупреждает');
|
||||
});
|
||||
|
||||
test('#659: browser-only mutations reuse one clean bundle unless their patch is bundled', () => {
|
||||
|
||||
@@ -134,6 +134,7 @@ const HOOK_FILES = [
|
||||
'scripts/pre-push-gate.mjs',
|
||||
'scripts/branch-state.mjs',
|
||||
'scripts/process-gate.mjs',
|
||||
'scripts/change-classes.mjs', // #701: классы изменений — общие для гейта и трейлеров
|
||||
'scripts/validate-commit-provenance.mjs',
|
||||
'scripts/bundle-policy.mjs', // #657: правило бандла в проверке происхождения
|
||||
'scripts/bundle-tree.mjs',
|
||||
|
||||
@@ -50,7 +50,7 @@ const KEY_RULES = {
|
||||
['5-треки-ship-show-ask--метка-владельца', 'ожидаемое поведение уже зафиксировано'],
|
||||
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
|
||||
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
|
||||
['26-в-разработке--реализация', 'каждый коммит несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
|
||||
['26-в-разработке--реализация', 'каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
|
||||
['3-правила', '`User-Visible: yes` требует правок в обоих changelog в том же коммите'],
|
||||
['26-в-разработке--реализация', 'шесть классов риска'],
|
||||
['26-в-разработке--реализация', 'Скоуп не расширяется'],
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// #699, PROCESS.md §8: полоса над потолком беты вместо точки; потолки
|
||||
// опускает до факта релиз-менеджер одной командой на кандидате.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
BUNDLE_CEILINGS, CORE_BAND, CORE_BUDGET_FILE, BUNDLE_BUDGET_FILE, formatRow, ratchetRows, ratchetState,
|
||||
readConst, readCoreCaps, rewriteConst, rewriteCoreCaps,
|
||||
} from '../scripts/ratchets.mjs';
|
||||
import { METRIC_NAMES } from '../scripts/monolith-metrics.mjs';
|
||||
|
||||
const read = (path) => readFileSync(fileURLToPath(new URL(`../${path}`, import.meta.url)), 'utf8');
|
||||
|
||||
test('#699 состояние храповика: равен, рыхлый, в полосе, выше полосы, не измерен', () => {
|
||||
assert.equal(ratchetState(100, 100, 50), 'tight');
|
||||
assert.equal(ratchetState(90, 100, 50), 'loose');
|
||||
assert.equal(ratchetState(150, 100, 50), 'over', 'граница полосы включительная — как у гейтов ветки');
|
||||
assert.equal(ratchetState(151, 100, 50), 'grew');
|
||||
assert.equal(ratchetState(undefined, 100, 50), 'missing');
|
||||
assert.equal(ratchetState(100, null, 50), 'missing');
|
||||
});
|
||||
|
||||
test('#699 потолки ядер читаются и переписываются в исходнике теста, комментарии на месте', () => {
|
||||
const source = read(CORE_BUDGET_FILE);
|
||||
const caps = readCoreCaps(source);
|
||||
// Имена ядер здесь не пишутся: тесты, читающие монолит как текст, заморожены (#624).
|
||||
const [first, second] = Object.keys(caps);
|
||||
assert.equal(Object.keys(caps).length, 2, 'два ядра');
|
||||
const next = rewriteCoreCaps(source, { [first]: 12000 });
|
||||
assert.equal(readCoreCaps(next)[first], 12000);
|
||||
assert.equal(readCoreCaps(next)[second], caps[second]);
|
||||
assert.equal(next.split('\n').length, source.split('\n').length, 'меняется только число');
|
||||
assert.match(next, /2026-09-27, #676/, 'история решений остаётся');
|
||||
assert.match(source, new RegExp(`^export const CORE_BAND = ${CORE_BAND};$`, 'm'), 'полоса теста и инструмента — одно число');
|
||||
});
|
||||
|
||||
test('#699 потолки графов читаются и переписываются с разрядами', () => {
|
||||
const source = read(BUNDLE_BUDGET_FILE);
|
||||
for (const entry of BUNDLE_CEILINGS) assert.ok(readConst(source, entry.name) > 0, entry.name);
|
||||
const next = rewriteConst(source, 'INITIAL_VIEW_GZIP_CEILING', 300323);
|
||||
assert.equal(readConst(next, 'INITIAL_VIEW_GZIP_CEILING'), 300323);
|
||||
assert.match(next, /^export const INITIAL_VIEW_GZIP_CEILING = 300_323;$/m);
|
||||
assert.throws(() => readConst(source, 'NO_SUCH_CEILING'), /нет export const NO_SUCH_CEILING/);
|
||||
});
|
||||
|
||||
test('#699 отчёт видит все три вида храповиков и называет, что делать', () => {
|
||||
const rows = ratchetRows({
|
||||
coreCaps: { 'a.ts': 100 }, coreFacts: { 'a.ts': 90 },
|
||||
bundleCeilings: Object.fromEntries(BUNDLE_CEILINGS.map((e) => [e.name, 1000])),
|
||||
bundleFacts: Object.fromEntries(BUNDLE_CEILINGS.map((e) => [e.metric, 1000])),
|
||||
metrics: Object.fromEntries(METRIC_NAMES.map((n) => [n, 10])),
|
||||
baseline: Object.fromEntries(METRIC_NAMES.map((n) => [n, n === 'hostRefs' ? 5 : 10])),
|
||||
});
|
||||
assert.equal(rows.length, 1 + BUNDLE_CEILINGS.length + METRIC_NAMES.length);
|
||||
assert.equal(rows.find((r) => r.kind === 'core').state, 'loose');
|
||||
assert.ok(rows.filter((r) => r.kind === 'bundle').every((r) => r.state === 'tight'));
|
||||
assert.equal(rows.find((r) => r.name === 'hostRefs').state, 'over');
|
||||
assert.match(formatRow(rows.find((r) => r.kind === 'core')), /факт 90 · потолок 100 \(-10, полоса \+50\) — рыхлый/);
|
||||
});
|
||||
|
||||
test('#699 публикация беты напоминает о храповиках, но не останавливается на них', () => {
|
||||
const local = read('scripts/release-prerelease.mjs');
|
||||
const main = local.slice(local.indexOf('const main = async'));
|
||||
assert.match(main, /'scripts\/ratchets\.mjs', 'report', '--warn'/);
|
||||
assert.match(main, /'scripts\/ratchets\.mjs', 'report', '--warn'\], \{ allowFailure: true, inherit: true \}\)/);
|
||||
});
|
||||
|
||||
test('#699 r1 M1: runbook беты опускает храповики при подготовке кандидата, до публикации', () => {
|
||||
const runbook = read('docs/DEVELOPMENT.md');
|
||||
const prepare = runbook.slice(runbook.indexOf('Prepare the candidate as usual'), runbook.indexOf('npm run release:prerelease --'));
|
||||
assert.ok(prepare.length > 0, 'раздел подготовки кандидата найден');
|
||||
assert.match(prepare, /`npm run bundle:release`[\s\S]*`node scripts\/ratchets\.mjs tighten`/, 'tighten — после свежего dist/');
|
||||
assert.match(prepare, /commit them with the candidate/);
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { REVIEWS_INDEX_PATH, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs';
|
||||
import { REVIEWS_INDEX_PATH, UPSTREAM_WINS, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs';
|
||||
import { PATCH_ID_EXCLUDES } from '../scripts/merge-candidate.mjs';
|
||||
import { buildIndex } from '../scripts/reviews-index.mjs';
|
||||
|
||||
// #643: doc-коммит ветки задачи конфликтует с dev только в генерируемом
|
||||
@@ -106,7 +107,7 @@ function scenario({ shared = false, tools = false } = {}) {
|
||||
|
||||
test('#643 planStop: разрешается только набор, где ВСЕ конфликты — индекс или объявленные вызывающим', () => {
|
||||
assert.deepEqual(planStop([REVIEWS_INDEX_PATH, '', ` ${REVIEWS_INDEX_PATH}`]),
|
||||
{ action: 'resolve', index: true, extra: [], conflicts: [REVIEWS_INDEX_PATH] });
|
||||
{ action: 'resolve', index: true, upstream: [], extra: [], conflicts: [REVIEWS_INDEX_PATH] });
|
||||
const mixed = planStop([REVIEWS_INDEX_PATH, 'src/x.ts']);
|
||||
assert.equal(mixed.action, 'abort');
|
||||
assert.equal(mixed.reason, 'manual');
|
||||
@@ -116,7 +117,7 @@ test('#643 planStop: разрешается только набор, где ВС
|
||||
assert.equal(planStop(['docs/reviews/sub/INDEX.md']).action, 'abort', 'другой INDEX.md — не индекс ревью');
|
||||
assert.deepEqual(planStop([]), { action: 'abort', reason: 'no-conflicts', manual: [], conflicts: [] });
|
||||
const bundle = planStop(['dist/a.js', REVIEWS_INDEX_PATH], { extra: (p) => p.startsWith('dist/') });
|
||||
assert.deepEqual(bundle, { action: 'resolve', index: true, extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] });
|
||||
assert.deepEqual(bundle, { action: 'resolve', index: true, upstream: [], extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] });
|
||||
});
|
||||
|
||||
test('#643 AC1: конфликт только в INDEX.md — ребейз проходит, индекс равен пересборке каталога', () => {
|
||||
@@ -293,3 +294,73 @@ test('#643 process.yml на настоящем bash: сбой помощника
|
||||
assert.match(r.stdout, /::error::помощник ребейза упал/);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// ---------- #698: ченджлог объединяется, база метрик берётся из dev ----------
|
||||
|
||||
function sharedFilesScenario({ baselineConflict = false, alsoCode = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-698-'));
|
||||
const work = join(root, 'work');
|
||||
mkdirSync(join(work, 'docs'), { recursive: true });
|
||||
mkdirSync(join(work, 'scripts'), { recursive: true });
|
||||
git(root, 'init', '-q', '-b', 'dev', work);
|
||||
copyFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), join(work, '.gitattributes'));
|
||||
const log = (lines) => writeFileSync(join(work, 'docs', 'CHANGELOG.md'), `# Changelog\n\n## Unreleased\n\n${lines.join('\n')}\n\n## 1.0.0\n\n- first\n`);
|
||||
const baseline = (hostRefs) => writeFileSync(join(work, 'scripts', 'monolith-baseline.json'), `${JSON.stringify({ delegates: 1, hostRefs }, null, 2)}\n`);
|
||||
log([]); baseline(100);
|
||||
writeFileSync(join(work, 'a.mjs'), 'export const a = 1;\n');
|
||||
commitAll(work, 'base');
|
||||
git(work, 'checkout', '-q', '-b', 'issue/9-x');
|
||||
log(['- task nine']);
|
||||
if (baselineConflict) baseline(95);
|
||||
if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 9;\n');
|
||||
commitAll(work, 'task');
|
||||
git(work, 'checkout', '-q', 'dev');
|
||||
log(['- task eight']);
|
||||
if (baselineConflict) baseline(103);
|
||||
if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 8;\n');
|
||||
commitAll(work, 'neighbour');
|
||||
git(work, 'checkout', '-q', 'issue/9-x');
|
||||
return { root, work };
|
||||
}
|
||||
|
||||
test('#698: записи ченджлога двух задач объединяются при ребейзе, конфликта нет', (t) => {
|
||||
const { root, work } = sharedFilesScenario();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
assert.equal(result.stops, 0, 'union — это не остановка ребейза');
|
||||
const text = readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8');
|
||||
assert.match(text, /- task eight\n- task nine\n/, 'обе записи в Unreleased, сторона dev первой');
|
||||
assert.doesNotMatch(text, /^(<<<<<<<|=======|>>>>>>>)/m);
|
||||
});
|
||||
|
||||
test('#698: конфликт в базе метрик монолита решается в пользу dev', (t) => {
|
||||
const { root, work } = sharedFilesScenario({ baselineConflict: true });
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
assert.deepEqual(result.resolved, ['scripts/monolith-baseline.json ← dev']);
|
||||
assert.equal(JSON.parse(readFileSync(join(work, 'scripts', 'monolith-baseline.json'), 'utf8')).hostRefs, 103);
|
||||
assert.match(readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8'), /- task nine/);
|
||||
assert.deepEqual(UPSTREAM_WINS, ['scripts/monolith-baseline.json']);
|
||||
});
|
||||
|
||||
test('#698: база метрик вместе с конфликтом в коде — прежний отказ с перечнем', (t) => {
|
||||
const { root, work } = sharedFilesScenario({ baselineConflict: true, alsoCode: true });
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const before = git(work, 'rev-parse', 'HEAD');
|
||||
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(result.manual, ['a.mjs']);
|
||||
assert.deepEqual(result.conflicts, ['a.mjs', 'scripts/monolith-baseline.json']);
|
||||
assert.equal(git(work, 'rev-parse', 'HEAD'), before, 'ребейз отменён, HEAD как был');
|
||||
});
|
||||
|
||||
test('#698: patch-id кандидата не видит того, что ребейз сливает сам', () => {
|
||||
for (const path of ['docs/reviews', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md', 'scripts/monolith-baseline.json']) {
|
||||
assert.ok(PATCH_ID_EXCLUDES.includes(`:!${path}`), path);
|
||||
}
|
||||
const attrs = readFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), 'utf8');
|
||||
assert.match(attrs, /^docs\/CHANGELOG\.md merge=union$/m);
|
||||
assert.match(attrs, /^docs\/CHANGELOG\.ru\.md merge=union$/m);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { selectSmokes, parseDiff, symbolTable } from '../scripts/smoke-select.mjs';
|
||||
import { selectSmokes, parseDiff, symbolTable, VISUAL_MINIMUM } from '../scripts/smoke-select.mjs';
|
||||
import { smokesToRun } from '../scripts/gate-small.mjs';
|
||||
import { SMOKE_LINKS, registeredSmokes } from '../scripts/smoke-links.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -75,6 +77,30 @@ test('связь не доказана — это неопределённост
|
||||
'символ без смока обязан быть назван');
|
||||
});
|
||||
|
||||
test('#690 п.1′: неопределённость выдаёт визуальный минимум, доказанная связь и docs — нет', () => {
|
||||
const unproven = selectSmokes(fixture('unproven.diff'), { root: repoRoot });
|
||||
assert.deepEqual(unproven.visualMinimum, [...VISUAL_MINIMUM]);
|
||||
for (const smoke of VISUAL_MINIMUM) {
|
||||
assert.ok(smokesToRun(unproven).includes(smoke), `gate:small -- --smokes не гоняет ${smoke}`);
|
||||
}
|
||||
assert.deepEqual(selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot }).visualMinimum, [],
|
||||
'при доказанной связи минимум не нужен — выборка остаётся меньше матрицы');
|
||||
const docs = selectSmokes(fixture('docs-only.diff'), { root: repoRoot });
|
||||
assert.deepEqual(docs.visualMinimum, []);
|
||||
assert.deepEqual(smokesToRun(docs), [], 'без исполняемого диффа смоков нет');
|
||||
});
|
||||
|
||||
test('#690 п.1′: визуальный минимум — 5–8 существующих смоков, и CLI его печатает', () => {
|
||||
assert.ok(VISUAL_MINIMUM.length >= 5 && VISUAL_MINIMUM.length <= 8, `в минимуме ${VISUAL_MINIMUM.length}`);
|
||||
for (const smoke of VISUAL_MINIMUM) assert.ok(existsSync(join(repoRoot, 'demo', smoke)), `нет demo/${smoke}`);
|
||||
assert.ok(VISUAL_MINIMUM.includes('smoke_modes.mjs'), 'смок, которого не хватило #687');
|
||||
const cli = spawnSync(process.execPath, ['scripts/smoke-select.mjs', '--diff', 'test/fixtures/smoke-select/unproven.diff'],
|
||||
{ cwd: repoRoot, encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr);
|
||||
assert.match(cli.stdout, /Визуальный минимум \(\d\) — прогнать до S7/);
|
||||
for (const smoke of VISUAL_MINIMUM) assert.ok(cli.stdout.includes(`demo/${smoke}`), smoke);
|
||||
});
|
||||
|
||||
test('таблица символов не берёт одиночные английские слова (#241)', () => {
|
||||
const table = symbolTable(repoRoot);
|
||||
for (const noise of ['floor', 'value', 'index', 'return', 'length', 'edit']) {
|
||||
|
||||
@@ -357,3 +357,43 @@ test('#669 AC2 a maximum stair collection keeps the room area and passes a bound
|
||||
const actual = geometryAreaMinusStairs(room, stairs);
|
||||
assert.ok(Math.abs(actual - expected) <= Math.max(expected, 1) * 1e-9, `${actual} vs ${expected}`);
|
||||
});
|
||||
|
||||
// #693: в View над лестницей был курсор `move` — редакторское правило для
|
||||
// `.hp-stair-hit` задевало и слой View, который ставит `input-enabled` только
|
||||
// ради попадания. Браузерное доказательство — demo/smoke_stairs.mjs; здесь
|
||||
// каскад закреплён без Chromium.
|
||||
test('#693 курсор move над телом лестницы — только в редакторе плана', async () => {
|
||||
const { planStyles } = await import('../test-build/styles.js');
|
||||
const css = planStyles.cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
||||
.map(([, selectors, body]) => [selectors.trim().split(/\s*,\s*/), body]);
|
||||
const cursorOf = (body) => /(?:^|;)\s*cursor\s*:\s*([^;]+)/.exec(body)?.[1].trim() ?? null;
|
||||
// Селектор вида «<составной селектор группы> .hp-stair-hit»; иная форма —
|
||||
// повод расширить тест, а не молча её пропустить.
|
||||
const matchesGroup = (compound, classes) => {
|
||||
const parts = compound.match(/:not\(\.[\w-]+\)|\.[\w-]+|[^.:]+|:[\w-]+/g) ?? [];
|
||||
return parts.every((part) => {
|
||||
if (part.startsWith(':not(.')) return !classes.includes(part.slice(6, -1));
|
||||
if (part.startsWith('.')) return classes.includes(part.slice(1));
|
||||
throw new Error(`#693: неразобранная часть селектора «${part}» в «${compound}»`);
|
||||
});
|
||||
};
|
||||
const hitCursor = (classes) => rules
|
||||
.flatMap(([selectors, body]) => selectors
|
||||
.filter((selector) => /\s\.hp-stair-hit$/.test(selector) && cursorOf(body))
|
||||
.map((selector) => [selector.replace(/\s+\.hp-stair-hit$/, '').trim(), cursorOf(body)]))
|
||||
.filter(([compound]) => {
|
||||
assert.ok(!/\s/.test(compound), `#693: предок с потомком в «${compound}» — расширить тест`);
|
||||
return matchesGroup(compound, classes);
|
||||
})
|
||||
.map(([, cursor]) => cursor);
|
||||
assert.deepEqual(hitCursor(['hp-stair', 'input-enabled']), ['move'], 'редактор плана тащит лестницу за тело');
|
||||
assert.deepEqual(hitCursor(['hp-stair', 'hp-stair-view', 'navigable', 'input-enabled']), [],
|
||||
'в View у области попадания своего курсора нет — виден pointer ссылки');
|
||||
assert.deepEqual(hitCursor(['hp-stair', 'hp-stair-view', 'input-enabled']), [],
|
||||
'лестница без цели в View — курсор сцены, не move');
|
||||
assert.ok(rules.some(([selectors, body]) => selectors.includes('.hp-stair.navigable') && cursorOf(body) === 'pointer'),
|
||||
'ссылка несёт pointer на группе');
|
||||
const view = readFileSync(new URL('../src/stairs-view.ts', import.meta.url), 'utf8');
|
||||
assert.match(view, /<g class="hp-stair hp-stair-view /, 'слой View помечает свои лестницы');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// #706: исход rereview возвращает задачу в ту же метку `S7-code-review`, и
|
||||
// одним вызовом `gh` она добавлялась и тут же снималась — задача оставалась без
|
||||
// статуса, новый заход не стартовал.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { moveStatusLabel } from '../scripts/status-label.mjs';
|
||||
|
||||
const REPO = 'Matysh/houseplan-card';
|
||||
const recorder = (fail = () => false) => {
|
||||
const calls = [];
|
||||
const execute = (args, opts = {}) => {
|
||||
calls.push(args.slice(5).join(' '));
|
||||
const status = fail(args, calls.length) ? 1 : 0;
|
||||
if (status && !opts.allowFailure) throw new Error(`gh ${args.join(' ')} → boom`);
|
||||
return { status, stderr: status ? 'boom' : '' };
|
||||
};
|
||||
return { calls, execute };
|
||||
};
|
||||
|
||||
test('#706 rereview: та же метка снимается и ставится заново отдельными вызовами', () => {
|
||||
const { calls, execute } = recorder();
|
||||
const how = moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute });
|
||||
assert.equal(how, 'relabeled');
|
||||
assert.deepEqual(calls, ['--remove-label S7-code-review', '--add-label S7-code-review'],
|
||||
'снятие, затем постановка — событие labeled запускает новый заход');
|
||||
});
|
||||
|
||||
test('#706 обычный исход — одна правка: новая метка ставится, прежняя снимается', () => {
|
||||
const { calls, execute } = recorder();
|
||||
assert.equal(moveStatusLabel({ repo: REPO, issue: 700, from: 'S7-code-review', to: 'S8-merged', execute }), 'moved');
|
||||
assert.deepEqual(calls, ['--add-label S8-merged --remove-label S7-code-review']);
|
||||
const none = recorder();
|
||||
moveStatusLabel({ repo: REPO, issue: 700, from: '', to: 'S6-in-progress', execute: none.execute });
|
||||
assert.deepEqual(none.calls, ['--add-label S6-in-progress'], 'без исходной метки снимать нечего');
|
||||
});
|
||||
|
||||
test('#706 сбой повторной постановки роняет шаг, а не оставляет задачу без статуса молча', () => {
|
||||
const once = recorder((args, n) => n === 2);
|
||||
moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute: once.execute });
|
||||
assert.deepEqual(once.calls, ['--remove-label S7-code-review', '--add-label S7-code-review', '--add-label S7-code-review'],
|
||||
'одна попытка восстановления, как в автосверке (#555)');
|
||||
const always = recorder((args) => args.includes('--add-label'));
|
||||
assert.throws(() => moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute: always.execute }),
|
||||
/could not restore S7-code-review/);
|
||||
assert.throws(() => moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: '', execute: always.execute }), /usage/);
|
||||
});
|
||||
|
||||
test('#706 шаг конвейера переставляет метку через скрипт, а не одним вызовом gh', () => {
|
||||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_process.yml', import.meta.url)), 'utf8');
|
||||
const step = workflow.slice(workflow.indexOf('- name: Переставить метку'), workflow.indexOf('- name: Сводка длительности стадий'));
|
||||
assert.ok(step.length > 0, 'шаг найден');
|
||||
assert.match(step, /node scripts\/status-label\.mjs --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/);
|
||||
assert.doesNotMatch(step, /gh issue edit/, 'совмещённый вызов снимал ту же метку, которую ставил');
|
||||
});
|
||||
@@ -198,7 +198,7 @@ test('#632 r1: trivial issue in S6/S7 keeps class A rights without any spec arte
|
||||
assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
|
||||
}
|
||||
assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', labels: ['trivial'], issue: { body } }),
|
||||
['короткий трек trivial (ТЗ не пишется, §5.1)']);
|
||||
['прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)']);
|
||||
assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', labels: ['small', 'infra'], issue: { body } }), [],
|
||||
'только trivial: small несёт ТЗ в теле и доказывается разделом «## ТЗ»');
|
||||
});
|
||||
|
||||
@@ -440,7 +440,7 @@ test('смоки, golden и performance_smoke условны по heavy (#479)',
|
||||
assert.match(text, /classify-changes\.mjs --heavy/);
|
||||
assert.match(text, /workflow_dispatch:\n\s+inputs:\n\s+full:/);
|
||||
// preflight: режим скриншотов считает тот же скрипт.
|
||||
assert.match(text, /check-docs\.mjs --external --screenshots=\$mode/);
|
||||
assert.match(text, /check-docs\.mjs "\$external" --screenshots=\$mode/);
|
||||
});
|
||||
|
||||
test('ночной прогон — dispatch Validate на dev с full=true (#479)', () => {
|
||||
@@ -613,3 +613,30 @@ test('#541: Validate всегда публикует proof точной попы
|
||||
assert.equal(reuse.includes('lookup-only: true'), false,
|
||||
'marker contents must be restored and verified, not reduced to a cache-hit bit');
|
||||
});
|
||||
|
||||
test('#700: на ветке задачи зеркало workflow и внешние ссылки — предупреждение, на dev — красный и issue', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const preflight = workflow.slice(workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'));
|
||||
const docs = preflight.slice(preflight.indexOf('id: docs'), preflight.indexOf('id: reviews_index'));
|
||||
assert.match(docs, /case "\$REF" in refs\/heads\/issue\/\*\) external=--external=warn ;; esac/);
|
||||
assert.match(docs, /REF: \$\{\{ github\.ref \}\}/);
|
||||
const verdict = preflight.slice(preflight.indexOf('- name: Вердикт предполётных проверок'));
|
||||
assert.match(verdict, /case "\$REF" in refs\/heads\/issue\/\*\) task_branch=true ;; esac/);
|
||||
assert.match(verdict, /if \[ "\$task_branch" = "true" \]; then\n\s+advise "тонкие вызывающие workflow в main и dev" "\$WORKFLOW_SYNC"\n\s+else\n\s+check "тонкие вызывающие workflow в main и dev"/);
|
||||
assert.doesNotMatch(verdict.slice(verdict.indexOf('advise() {'), verdict.indexOf('task_branch=false')), /fail=1/,
|
||||
'предупреждение не красит вердикт');
|
||||
const issue = preflight.slice(preflight.indexOf('- name: "Расхождение зеркала на dev — issue владельцу"'));
|
||||
assert.match(issue, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/dev' && steps\.workflow_sync\.outcome == 'failure'/);
|
||||
assert.match(issue, /gh issue list --repo "\$REPO" --state open --search/, 'одно issue, а не одно на каждый push');
|
||||
// r1 #700: несчитанный список — не повод заводить новое.
|
||||
assert.match(issue, /if ! existing=\$\(gh issue list [\s\S]*?\); then\n\s+echo "::warning::[^"]*"\n\s+exit 0\n\s+fi/);
|
||||
assert.doesNotMatch(issue.slice(0, issue.indexOf('gh issue create')), /\|\| true\)/, 'сбой чтения не глушится в пустой ответ');
|
||||
assert.match(preflight, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: write/);
|
||||
});
|
||||
|
||||
test('#700: check-docs --external=warn сводит внешние отказы в предупреждения', () => {
|
||||
const source = read('../../scripts/check-docs.mjs');
|
||||
assert.match(source, /const EXTERNAL_WARN = process\.argv\.includes\('--external=warn'\);/);
|
||||
assert.match(source, /const externalErrors = EXTERNAL_WARN \? warnings : errors;/);
|
||||
assert.equal((source.match(/externalErrors\.push\(/g) || []).length, 2, 'оба вида внешнего отказа');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user