mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fa570642df | ||
|
|
2a62ad5b95 | ||
|
|
c716bb0f63 | ||
|
|
ac75df7e9a | ||
|
|
e1ae8f4ac7 | ||
|
|
9e4bfb4379 | ||
|
|
d8465b3a09 | ||
|
|
f6c76b7351 | ||
|
|
51854aea53 | ||
|
|
57ce10721f |
@@ -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
|
||||
|
||||
+195
-28
@@ -58,6 +58,8 @@ jobs:
|
||||
cycle: ${{ steps.decide.outputs.cycle }}
|
||||
spent: ${{ steps.decide.outputs.spent }}
|
||||
limit: ${{ steps.decide.outputs.limit }}
|
||||
# #696: текущие метки — prepare выводит из них трек захода.
|
||||
labels: ${{ steps.decide.outputs.labels }}
|
||||
steps:
|
||||
# Мелкий checkout: guard остаётся лёгким, но ему нужен
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
@@ -84,8 +86,15 @@ jobs:
|
||||
BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false
|
||||
has blocked && BLOCKED=true
|
||||
has review-4 && EXHAUSTED=true
|
||||
# Треки #695 (PROCESS §5): `track:show` и `track:ship` — лимит 2, как у
|
||||
# прежних `small`/`trivial`; `track:ask` — 4. Цену захода по треку
|
||||
# (мутанты, ребейз, модель) решает prepare (#696).
|
||||
has small && SMALL=true
|
||||
has trivial && TRIVIAL=true
|
||||
has track:show && SMALL=true
|
||||
has track:ship && SMALL=true
|
||||
# Явный `track:ask` главнее прежних меток: метка владельца окончательна.
|
||||
if has track:ask; then SMALL=false; TRIVIAL=false; fi
|
||||
|
||||
# Этап определяется первым: от него зависит, какие вердикты считать.
|
||||
stage=""; marker=""
|
||||
@@ -102,7 +111,7 @@ jobs:
|
||||
stage=""
|
||||
fi
|
||||
|
||||
# Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4).
|
||||
# Лимит циклов: 4 на `track:ask`, 2 на `track:show`/`track:ship` (PROCESS.md §4, §5).
|
||||
limit=4
|
||||
if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi
|
||||
|
||||
@@ -213,6 +222,26 @@ jobs:
|
||||
- документы: $new_blocking"
|
||||
fi
|
||||
echo "ветка материала: ${branch:-нет, читался dev}"
|
||||
|
||||
# §5.1 (r1 #695): инфраструктурная задача (§1) без трековой метки
|
||||
# идёт как `track:show` — лимит 2. Признак механический, как в §1:
|
||||
# в диффе ветки против dev ни одного файла класса A. Guard историю
|
||||
# не клонирует, поэтому пути — из compare API; 300 файлов — потолок
|
||||
# ответа, и полный список тогда не доказан: остаётся лимит 4.
|
||||
if [ -n "$branch" ] && ! has track:ship && ! has track:show && ! has track:ask \
|
||||
&& ! has small && ! has trivial; then
|
||||
changed=$(mktemp)
|
||||
if gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
|
||||
&& node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
import { classify } from "./scripts/process-gate.mjs";
|
||||
const files = readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean);
|
||||
process.exit(files.length > 0 && files.length < 300 && files.every((f) => classify(f) !== "A") ? 0 : 1);
|
||||
' "$changed"; then
|
||||
limit=2
|
||||
echo "инфраструктурная задача без трековой метки — трек show (§5.1), лимит 2"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Отказ обязан быть виден в issue, а не только в логе прогона.
|
||||
@@ -274,6 +303,7 @@ jobs:
|
||||
echo "cycle=$attempt" >> "$GITHUB_OUTPUT"
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
echo "labels=$(printf '%s\n' "$current" | paste -sd, -)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
prepare:
|
||||
name: "Ревью: материал и deterministic gates"
|
||||
@@ -309,6 +339,10 @@ jobs:
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
track: ${{ steps.track.outputs.track }}
|
||||
mutants: ${{ steps.track.outputs.mutants }}
|
||||
full: ${{ steps.track.outputs.full }}
|
||||
ship: ${{ steps.track.outputs.ship }}
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
@@ -379,6 +413,59 @@ jobs:
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутанты по диффу —
|
||||
# только `track:ask` и метка `ci:mutants`; `ship` сливается без модели,
|
||||
# если правка укладывается в механические рамки. Рамки судят дифф от
|
||||
# merge-base с dev — работу задачи, а не то, что принёс dev, — поэтому шаг
|
||||
# стоит до ребейза. Выход за рамки повышает трек до `show` здесь же:
|
||||
# повысить трек вправе любой, понизить — только владелец (§5). Скрипт
|
||||
# берётся из dev, как помощник ребейза: show/ship до ревью не ребейзятся,
|
||||
# и ветка, отставшая от dev, его может ещё не нести.
|
||||
- name: "Трек задачи и рамки ship (#696)"
|
||||
id: track
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
LABELS: ${{ needs.guard.outputs.labels }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
tools="$RUNNER_TEMP/track-tools"
|
||||
rm -rf "$tools" && mkdir -p "$tools"
|
||||
git archive origin/dev scripts | tar -x -C "$tools"
|
||||
head=HEAD
|
||||
if [ -z "$BRANCH" ]; then head=origin/dev; fi
|
||||
out=$(node "$tools/scripts/process-track.mjs" resolve --labels="$LABELS" --base=origin/dev --head="$head")
|
||||
printf '%s\n' "$out"
|
||||
track=$(printf '%s\n' "$out" | sed -n 's/^track=//p')
|
||||
mutants=$(printf '%s\n' "$out" | sed -n 's/^mutants=//p')
|
||||
full=$(printf '%s\n' "$out" | sed -n 's/^full=//p')
|
||||
ship=false
|
||||
if [ "$track" = "ship" ] && [ "$STAGE" = "code" ] && [ -n "$BRANCH" ]; then
|
||||
limits=$(node "$tools/scripts/process-track.mjs" ship-limits --base=origin/dev --head=HEAD)
|
||||
printf '%s\n' "$limits"
|
||||
if printf '%s\n' "$limits" | grep -qx 'ship=true'; then
|
||||
ship=true
|
||||
else
|
||||
violations=$(printf '%s\n' "$limits" | sed -n 's/^violations=//p')
|
||||
track=show
|
||||
cat > /tmp/ship.md <<EOF
|
||||
**Трек повышен: \`track:ship\` → \`track:show\`.** Правка выходит за механические рамки ship (PROCESS.md §5): $violations.
|
||||
|
||||
Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC». Понизить трек обратно может только владелец.
|
||||
|
||||
[Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label track:show --remove-label track:ship
|
||||
echo "track:ship -> track:show: $violations"
|
||||
fi
|
||||
fi
|
||||
{ echo "track=$track"; echo "mutants=$mutants"; echo "full=${full:-false}"; echo "ship=$ship"; } >> "$GITHUB_OUTPUT"
|
||||
echo "- трек **$track** · мутанты по диффу: $mutants · полный набор: ${full:-false} · слияние без модели: $ship" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше
|
||||
# ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и
|
||||
# слитый SHA были разными коммитами. Пока расхождение с dev текстовое,
|
||||
@@ -400,6 +487,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
TRACK: ${{ steps.track.outputs.track }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
# «unable to auto-detect email address».
|
||||
@@ -414,6 +502,21 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
behind=$(git rev-list --count "HEAD..origin/dev")
|
||||
# #696: на show/ship ветка до ревью к dev не приводится, если
|
||||
# сливается с ним без конфликта. Ребейз — один раз, при слиянии, и
|
||||
# кандидат проходит Validate до push в dev (merge-candidate.mjs, #492):
|
||||
# в dev по-прежнему уезжает только проверенный CI SHA. Каждый сдвиг dev
|
||||
# больше не оплачивается force-push, новым SHA и новым прогоном до
|
||||
# ревью. На ask ребейз остаётся (#257): ревьюер читает то дерево,
|
||||
# которое уедет в dev.
|
||||
if [ "$TRACK" = "show" ] || [ "$TRACK" = "ship" ]; then
|
||||
if git merge-tree --write-tree origin/dev HEAD >/dev/null 2>&1; then
|
||||
echo "note=Ветка не приводилась к dev (трек $TRACK, #696): dev впереди на $behind коммит(ов), слияние без конфликта. Материал — ветка как есть; ребейз будет один раз, при слиянии, и кандидат пройдёт Validate до push в dev." >> "$GITHUB_OUTPUT"
|
||||
echo "трек $TRACK: dev впереди на $behind коммит(ов), слияние чистое — ребейз отложен до слияния"
|
||||
exit 0
|
||||
fi
|
||||
echo "трек $TRACK: слияние с dev не чистое — ветка приводится к dev до ревью"
|
||||
fi
|
||||
before=$(git rev-parse "origin/$BRANCH")
|
||||
echo "dev впереди на $behind коммит(ов) — привожу ветку"
|
||||
# #643: doc-коммит ветки конфликтует с документами других задач в dev
|
||||
@@ -600,13 +703,15 @@ jobs:
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||||
# запускает Validate с мутантами на материале и ждёт его. Красный или
|
||||
# запускает Validate на материале и ждёт его — с мутантами на `ask` и по
|
||||
# метке `ci:mutants`, лёгкий на `show`/`ship` (#696). Лёгкому годится и
|
||||
# завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или
|
||||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||||
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
|
||||
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
|
||||
# слияние само дожидается Validate на кандидате.
|
||||
- name: Validate с мутантами на материале
|
||||
- name: Validate на материале
|
||||
id: gate
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
@@ -615,6 +720,9 @@ jobs:
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
# #697: `ci:full`/`ci:golden` — полный набор на материале.
|
||||
FULL: ${{ steps.track.outputs.full }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
@@ -628,7 +736,8 @@ jobs:
|
||||
# S7), страховка — process-reconcile. Зелёный или красный завершённый
|
||||
# прогон гейт и без ожидания возвращает сразу.
|
||||
set +e
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait
|
||||
node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \
|
||||
--mutants="${MUTANTS:-true}" --full="${FULL:-false}"
|
||||
code=$?
|
||||
set -e
|
||||
case "$code" in
|
||||
@@ -653,6 +762,7 @@ jobs:
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }}
|
||||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-pending"
|
||||
mkdir -p "$dir"
|
||||
@@ -666,7 +776,9 @@ jobs:
|
||||
(cd "$dir" && sha256sum pending.json > manifest.sha256)
|
||||
echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY"
|
||||
kind="Validate с мутантами"
|
||||
if [ "$MUTANTS" = "false" ]; then kind="Лёгкий Validate"; fi
|
||||
echo "$kind на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Сохранить маркер ожидания
|
||||
if: steps.gate.outputs.proceed == 'pending'
|
||||
@@ -687,10 +799,13 @@ jobs:
|
||||
RESULT: ${{ steps.gate.outputs.result }}
|
||||
NOTE: ${{ steps.gate.outputs.note }}
|
||||
URL: ${{ steps.gate.outputs.url }}
|
||||
MUTANTS: ${{ steps.track.outputs.mutants }}
|
||||
run: |
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
kind="Validate с мутантами"
|
||||
if [ "$MUTANTS" = "false" ]; then kind="Validate"; fi
|
||||
cat > /tmp/gate.md <<EOF
|
||||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
**Ревью не запускалось:** $kind на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||||
|
||||
@@ -698,14 +813,14 @@ jobs:
|
||||
|
||||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||||
2. дождаться зелёного дешёвого Validate на пуше;
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит $kind и ревью.
|
||||
|
||||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
|
||||
echo "S7-code-review -> S6-in-progress ($kind: $RESULT)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
@@ -773,7 +888,7 @@ jobs:
|
||||
|
||||
- name: Собрать контракт материала между стадиями
|
||||
id: prepared
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.track.outputs.ship != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -809,7 +924,7 @@ jobs:
|
||||
echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Передать подтверждённый материал модели
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && steps.track.outputs.ship != 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.prepared.outputs.artifact }}
|
||||
@@ -852,7 +967,9 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
needs: [guard, prepare]
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
# #696: `ship` в рамках сливается без модели — пакетное ревью диапазона
|
||||
# перед бетой (PROCESS.md §11.7, ship-review.yml).
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
@@ -918,22 +1035,46 @@ jobs:
|
||||
test "$(git rev-parse HEAD)" = "$MATERIAL_SHA"
|
||||
test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE"
|
||||
|
||||
# #696: окружение ставится по нужде. Ревью ТЗ кода не исполняет — ни
|
||||
# `npm ci`, ни браузера. Ревью `show` ставит Chromium, только если тело
|
||||
# issue называет смоук или браузер; `ask` — как прежде, всегда. Тело не
|
||||
# прочитано — браузер ставится: лишняя минута дешевле непрогнанного смоука.
|
||||
- name: Что ревьюеру нужно из окружения
|
||||
id: env_needs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
TRACK: ${{ needs.prepare.outputs.track }}
|
||||
run: |
|
||||
deps=true; browser=true
|
||||
if [ "$STAGE" = "spec" ]; then deps=false; browser=false; fi
|
||||
if [ "$STAGE" = "code" ] && [ "$TRACK" = "show" ]; then
|
||||
if body=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json body --jq .body 2>/dev/null); then
|
||||
if ! printf '%s' "$body" | grep -qiE 'smoke|смоук|смок|playwright|браузер|demo/'; then browser=false; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "deps=$deps"; echo "browser=$browser"; } >> "$GITHUB_OUTPUT"
|
||||
echo "- окружение ревьюера: npm ci $deps · Chromium $browser" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.env_needs.outputs.deps == 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.env_needs.outputs.browser == 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.env_needs.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе раннера, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -993,6 +1134,9 @@ jobs:
|
||||
ничего не вернул на правки и цикла не образует (#227).
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
Трек: ${{ needs.prepare.outputs.track || 'ask' }} (PROCESS.md §5) · мутанты по диффу на материале: ${{ needs.prepare.outputs.mutants == 'false' && 'не запрашивались' || 'прогнаны Validate' }}
|
||||
|
||||
${{ needs.prepare.outputs.track == 'show' && needs.guard.outputs.stage == 'code' && 'Трек show: объём и серьёзность находок — docs/process/REVIEWER.md, «Трек show» (#696). Отсутствие мутантов по диффу — не находка.' || '' }}
|
||||
|
||||
${{ needs.prepare.outputs.rebase_note }}
|
||||
|
||||
@@ -1008,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:
|
||||
терминология интерфейса берётся оттуда, а не изобретается.
|
||||
@@ -1078,17 +1222,17 @@ jobs:
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`,
|
||||
`npm run build` со сверкой трёх копий бандла, плюс
|
||||
`node scripts/check-docs.mjs`, если diff трогает `src/**`.
|
||||
Зависимости уже установлены workflow, Chromium тоже — `npm ci`
|
||||
выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC
|
||||
`npm run build` со сверкой трёх копий бандла. Свежесть скриншотов
|
||||
документации — не гейт задачи (#697): её обновляет бот на dev.
|
||||
${{ needs.guard.outputs.stage == 'spec' && 'Зависимости и Chromium на этапе spec не ставились (#696).' || (needs.prepare.outputs.track == 'show' && 'Зависимости установлены (`npm ci` не нужен); Chromium — только если тело issue называет смоук (#696).' || 'Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно.') }}
|
||||
По диффу и AC: браузерные смоки — названные в AC
|
||||
плюс вывод `node scripts/smoke-select.mjs --base <base> --head <head>`,
|
||||
приложенный к комментарию с решением по каждой строке: прогнал либо
|
||||
не прогнал и почему. Три вида ответа инструмента разные: «прямое
|
||||
совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь
|
||||
не доказана, и это не разрешение ничего не прогонять; слабые связи —
|
||||
повод посмотреть, а не обязанность прогонять;
|
||||
`npm run golden:verify` при видимом изменении;
|
||||
`npm run golden:verify` при метке `ci:golden`;
|
||||
`python -m pytest tests_backend -q` при правке
|
||||
`custom_components/**/*.py`; инварианты модели
|
||||
`npm run invariants -- --config <экспорт>` при правке геометрии или
|
||||
@@ -1221,6 +1365,7 @@ jobs:
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
run: |
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
|
||||
@@ -1236,7 +1381,8 @@ jobs:
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
# #696: `ship` в рамках модель не вызывает — её результата не ждём.
|
||||
if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
echo "::error::стадия model review завершилась: $MODEL_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1255,7 +1401,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
@@ -1263,7 +1409,7 @@ jobs:
|
||||
|
||||
- name: Проверить полноту и происхождение результата
|
||||
id: result
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
# #556: единственное, что недоверенная стадия модели может передать
|
||||
# дальше, — этот artifact, и принимается он как ввод противника: полный
|
||||
# набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с
|
||||
@@ -1299,7 +1445,7 @@ jobs:
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
@@ -1472,7 +1618,7 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' && needs.prepare.outputs.ship != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
@@ -1497,10 +1643,28 @@ jobs:
|
||||
REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }}
|
||||
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
|
||||
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
if [ "$REUSE" = "true" ]; then
|
||||
# Повторно применимый зелёный вердикт (#499) главнее ship: код уже
|
||||
# прочитан моделью, и пакетному ревью перечитывать его незачем.
|
||||
if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then
|
||||
# #696: `ship` в рамках (PROCESS.md §5) сливается без ревью модели
|
||||
# после зелёного лёгкого Validate. Комментарий — не вердикт ревью и
|
||||
# так себя не называет: ревью не было. Машинный маркер находит
|
||||
# пакетное ревью диапазона перед бетой (ship-review.mjs, §11.7).
|
||||
verdict=green; high=0
|
||||
short=$(printf '%s' "$MATERIAL" | cut -c1-8)
|
||||
cat > /tmp/ship-merge.md <<EOF
|
||||
**Слияние без ревью модели: трек ship (PROCESS.md §5, #696).** Правка укладывается в механические рамки ship, лёгкий Validate на \`$short\` зелёный${VALIDATE_URL:+ ([прогон]($VALIDATE_URL))}. Код до слияния не читал никто: его прочтёт пакетное ревью диапазона перед бетой (PROCESS.md §11.7), документ \`docs/reviews/SHIP-REVIEW-<тег>.md\`.
|
||||
|
||||
<!-- hp:ship-merge material=$MATERIAL -->
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md
|
||||
elif [ "$REUSE" = "true" ]; then
|
||||
# Модель не вызывалась: вердикт — записанный конвейером зелёный
|
||||
# прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499).
|
||||
verdict=green; high=0
|
||||
@@ -1580,6 +1744,8 @@ jobs:
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
# #696: show/ship сливаются по лёгкому Validate на кандидате.
|
||||
MUTANTS: ${{ needs.prepare.outputs.mutants }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
@@ -1587,7 +1753,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
--issue="$NUM" --repo="${{ github.repository }}" --mutants="${MUTANTS:-true}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
@@ -1634,13 +1800,14 @@ jobs:
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
SHIP: ${{ needs.prepare.outputs.ship }}
|
||||
run: |
|
||||
stage="публикация/интеграция"
|
||||
detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён."
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
stage="deterministic prerequisites"
|
||||
detail="Модель не запускалась, цикл ревью не израсходован."
|
||||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
stage="model review"
|
||||
detail="Полного валидного результата модели нет; метка не менялась."
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
name: "Бета: производные артефакты на dev"
|
||||
run-name: "Derived artifacts ${{ inputs.tag }}"
|
||||
|
||||
# #697, PROCESS.md §8 и §11.4: ветки задач не коммитят ни отпечаток и кадры
|
||||
# скриншотов документации, ни эталоны golden. Их обновляет один коммит бота на
|
||||
# `dev` перед кандидатом беты; коммит проверяет релиз-менеджер.
|
||||
#
|
||||
# - Скриншоты: съёмка на `dev` тем же каноническим способом, что
|
||||
# `docs-screenshots.yml`, и приёмка `docs:accept --reviewed`. Кадры, которые
|
||||
# менять не собирались, обязаны совпасть байт-в-байт (свидетели среды);
|
||||
# изменившийся кадр принимается, только если назван в `docs_expect_change`.
|
||||
# Ничего не изменилось — обновляется один отпечаток исходников.
|
||||
# - Golden: артефакт `golden-images` полного Validate на `dev`
|
||||
# (`golden_run`) и `golden:accept --reviewed` с объявленными сценами. Коммит
|
||||
# несёт `Release:` и `Baseline-Reviewed:` — провенанс, который требует
|
||||
# validate-commit-provenance.mjs.
|
||||
#
|
||||
# Необъявленная разница — отказ с перечнем кадров, артефакт съёмки остаётся
|
||||
# для просмотра: решение, какие кадры сдвинулись законно, принимает человек.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Beta tag these artifacts are for, for example v1.79.0-beta.1"
|
||||
required: true
|
||||
type: string
|
||||
docs_expect_change:
|
||||
description: "Doc scenario ids expected to change, comma-separated (empty = none)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
golden_run:
|
||||
description: "Validate run id on dev whose golden-images artifact holds the shifted frames (empty = skip golden)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
golden_expect_change:
|
||||
description: "Golden scenes expected to change, comma-separated"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
golden_expect_new:
|
||||
description: "New golden scenes, comma-separated"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: beta-derived
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
accept:
|
||||
name: "Отпечаток, кадры и эталоны — одним коммитом в dev"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Install pinned Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
- name: Build the bundle the screenshots must come from
|
||||
run: npm run build
|
||||
# Тот же закреплённый упаковщик, что у docs-screenshots.yml: другой
|
||||
# переписал бы все кадры без единого изменённого пикселя.
|
||||
- name: Установить oxipng
|
||||
env:
|
||||
OXIPNG_VERSION: 10.2.0
|
||||
OXIPNG_SHA256: b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset="oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL -o "$asset" \
|
||||
"https://github.com/oxipng/oxipng/releases/download/v${OXIPNG_VERSION}/${asset}"
|
||||
echo "${OXIPNG_SHA256} ${asset}" | sha256sum -c -
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf "$asset" --strip-components=1 -C "$HOME/.local/bin" \
|
||||
"oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-gnu/oxipng"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/oxipng" --version
|
||||
- name: "Съёмка воспроизводима между прогонами (#410, #422)"
|
||||
run: node scripts/capture-determinism.mjs
|
||||
- name: Кадры документации — съёмка и приёмка
|
||||
id: docs
|
||||
env:
|
||||
EXPECT: ${{ inputs.docs_expect_change }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node demo/docs/capture.mjs --stability=3
|
||||
cand="$RUNNER_TEMP/docs-candidate"
|
||||
rm -rf "$cand" && mkdir -p "$cand"
|
||||
cp docs/images/*.png docs/images/screenshots.json "$cand/"
|
||||
# Приёмка сравнивает кандидата с ЗАКОММИЧЕННЫМИ кадрами: рабочая
|
||||
# копия возвращается к dev, и заменить файлы может только она.
|
||||
git checkout -- docs/images
|
||||
git clean -fdq -- docs/images
|
||||
args=(--reviewed "--from=$cand")
|
||||
if [ -n "$EXPECT" ]; then args+=("--expect-change=$EXPECT"); fi
|
||||
node scripts/docs-accept.mjs "${args[@]}"
|
||||
if git diff --quiet -- docs/images; then changed=false; else changed=true; fi
|
||||
echo "changed=$changed" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- docs/images
|
||||
- name: Сохранить кандидата скриншотов для просмотра
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: docs-candidate-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/docs-candidate
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Эталоны golden из прогона Validate
|
||||
id: golden
|
||||
if: inputs.golden_run != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN: ${{ inputs.golden_run }}
|
||||
EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
|
||||
EXPECT_NEW: ${{ inputs.golden_expect_new }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$RUN" in ''|*[!0-9]*) echo "::error::golden_run — числовой id прогона Validate"; exit 1 ;; esac
|
||||
meta=$(gh api "repos/${{ github.repository }}/actions/runs/$RUN" \
|
||||
--jq '[.path, .head_branch, .status, .html_url] | @tsv')
|
||||
IFS=$'\t' read -r path branch status url <<< "$meta"
|
||||
# Провенанс эталонов — завершённый Validate на dev: другой workflow
|
||||
# или ветка задачи доказательством для кандидата беты не являются.
|
||||
if [ "$path" != ".github/workflows/validate.yml" ] || [ "$branch" != "dev" ] || [ "$status" != "completed" ]; then
|
||||
echo "::error::прогон $RUN — не завершённый Validate на dev ($path, $branch, $status)"
|
||||
exit 1
|
||||
fi
|
||||
from="$RUNNER_TEMP/golden-candidate"
|
||||
rm -rf "$from" && mkdir -p "$from"
|
||||
gh run download "$RUN" --repo "${{ github.repository }}" -n golden-images -D "$from"
|
||||
# Отчёт обязан быть снят с этого же дерева: accept.mjs сверяет его
|
||||
# отпечаток с исходниками и откажет, если dev ушёл вперёд.
|
||||
args=(--reviewed "--from=$from")
|
||||
if [ -n "$EXPECT_CHANGE" ]; then args+=("--expect-change=$EXPECT_CHANGE"); fi
|
||||
if [ -n "$EXPECT_NEW" ]; then args+=("--expect-new=$EXPECT_NEW"); fi
|
||||
node scripts/golden-accept.mjs "${args[@]}"
|
||||
if git diff --quiet -- demo/golden/baselines; then changed=false; else changed=true; fi
|
||||
{ echo "changed=$changed"; echo "url=$url"; } >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- demo/golden/baselines
|
||||
- name: Коммит в dev
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOCS_CHANGED: ${{ steps.docs.outputs.changed }}
|
||||
DOCS_EXPECT: ${{ inputs.docs_expect_change }}
|
||||
GOLDEN_CHANGED: ${{ steps.golden.outputs.changed }}
|
||||
GOLDEN_URL: ${{ steps.golden.outputs.url }}
|
||||
GOLDEN_EXPECT_CHANGE: ${{ inputs.golden_expect_change }}
|
||||
GOLDEN_EXPECT_NEW: ${{ inputs.golden_expect_new }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# Хук pre-push гоняет process-gate; gate:small здесь не нужен —
|
||||
# продуктового кода коммит не несёт.
|
||||
HP_PREPUSH_GATE: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add -- docs/images demo/golden/baselines
|
||||
if git diff --cached --quiet; then
|
||||
echo "::notice::отпечаток, кадры и эталоны на dev уже свежие — коммитить нечего"
|
||||
exit 0
|
||||
fi
|
||||
msg="$RUNNER_TEMP/message.txt"
|
||||
{
|
||||
echo "docs: accept derived artifacts on dev for $TAG"
|
||||
echo ""
|
||||
echo "Производные артефакты беты — одним коммитом на dev (PROCESS.md §8, §11.4, #697)."
|
||||
echo "Прогон: $RUN_URL"
|
||||
if [ "$DOCS_CHANGED" = "true" ]; then
|
||||
echo "Скриншоты документации: отпечаток исходников; изменённые кадры: ${DOCS_EXPECT:-нет}."
|
||||
fi
|
||||
if [ "$GOLDEN_CHANGED" = "true" ]; then
|
||||
echo "Golden: изменённые сцены: ${GOLDEN_EXPECT_CHANGE:-нет}; новые: ${GOLDEN_EXPECT_NEW:-нет}."
|
||||
fi
|
||||
echo ""
|
||||
if [ "$GOLDEN_CHANGED" = "true" ]; then
|
||||
echo "Release: $TAG"
|
||||
echo "Baseline-Reviewed: $GOLDEN_URL"
|
||||
fi
|
||||
echo "Issue: #697"
|
||||
echo "User-Visible: no"
|
||||
} > "$msg"
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F "$msg"
|
||||
git show --stat --format='%H %s' HEAD | head -40
|
||||
if ! git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
|
||||
echo "::error::dev ушёл вперёд за время съёмки — запустить workflow заново: отпечаток судит дерево, а оно уже другое"
|
||||
exit 1
|
||||
fi
|
||||
echo "### Производные артефакты $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Коммит \`$(git rev-parse --short HEAD)\` в dev — проверить перед кандидатом беты." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -91,6 +91,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
# #696, PROCESS.md §11.7: задачи track:ship слиты без ревью модели — бета
|
||||
# публикуется, только когда их код прочитало пакетное ревью диапазона.
|
||||
- name: Ship tasks of the range are batch-reviewed
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.candidate.outputs.tag }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/ship-review.mjs check --tag="$TAG" --candidate="$SHA" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Bind issue membership to the exact candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
name: "Бета: пакетное ревью ship"
|
||||
run-name: "Ship review ${{ inputs.tag }}"
|
||||
|
||||
# #696, PROCESS.md §11.7: задачи `track:ship` сливаются без ревью модели —
|
||||
# правка в механических рамках и зелёный лёгкий Validate. Их код читает одна
|
||||
# сессия модели перед бетой: все ship-задачи диапазона «прошлый тег..кандидат».
|
||||
# Гейт публикации беты (`ship-review.mjs check`) требует этот документ, если в
|
||||
# диапазоне есть ship-задачи.
|
||||
#
|
||||
# Только `workflow_dispatch`: файл исполняется с ветки прогона (`--ref dev`),
|
||||
# зеркало в `main` не нужно. Три job, как у release-review.yml (#551, #556):
|
||||
# детерминированный вход, модель без права записи, детерминированная
|
||||
# публикация документа в `dev`.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Beta tag the review is for, for example v1.79.0-beta.1"
|
||||
required: true
|
||||
type: string
|
||||
candidate:
|
||||
description: "Exact candidate SHA; empty = the dev tip"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
force:
|
||||
description: "Review again even when the document already exists in dev"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ship-review-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: "Ship-ревью: задачи диапазона"
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
outputs:
|
||||
proceed: ${{ steps.range.outputs.proceed }}
|
||||
candidate: ${{ steps.range.outputs.candidate }}
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
doc: ${{ steps.range.outputs.doc }}
|
||||
issues: ${{ steps.range.outputs.issues }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Кандидат, база и ship-задачи
|
||||
id: range
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
CANDIDATE: ${{ inputs.candidate }}
|
||||
FORCE: ${{ inputs.force }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
doc=$(node scripts/ship-review.mjs doc --tag="$TAG")
|
||||
git fetch -q --tags origin
|
||||
if [ -z "$CANDIDATE" ]; then CANDIDATE=$(git rev-parse origin/dev); fi
|
||||
git cat-file -e "$CANDIDATE^{commit}"
|
||||
CANDIDATE=$(git rev-parse "$CANDIDATE^{commit}")
|
||||
if [ "$FORCE" != "true" ] && git cat-file -e "origin/dev:$doc" 2>/dev/null; then
|
||||
echo "::notice::$doc уже есть в dev — повторное ревью не запускается (force=true, чтобы переснять)"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
out="$RUNNER_TEMP/ship-review-input"
|
||||
node scripts/ship-review.mjs prepare --tag="$TAG" --candidate="$CANDIDATE" \
|
||||
--out="$out" --repo="${{ github.repository }}" --run-url="$RUN_URL" | tee "$RUNNER_TEMP/range.txt"
|
||||
cat "$RUNNER_TEMP/range.txt" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate=$CANDIDATE" >> "$GITHUB_OUTPUT"
|
||||
issues=$(sed -n 's/^issues=//p' "$RUNNER_TEMP/range.txt")
|
||||
if [ -z "$issues" ]; then
|
||||
echo "::notice::ship-задач в диапазоне нет — ревью не нужно"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
(cd "$out" && sha256sum brief.md ship-issues.json > manifest.sha256)
|
||||
cat "$out/brief.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Передать вход модели
|
||||
if: steps.range.outputs.proceed == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
model_review:
|
||||
name: "Ship-ревью: работа модели"
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.proceed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# Недоверенная стадия без права записи (#556): документ публикует
|
||||
# `publish`. `github_token` у шага Review обязателен — без него action
|
||||
# меняет OIDC на собственный App-токен с правом записи.
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.candidate }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Получить вход
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-input-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-input
|
||||
|
||||
- name: Проверить вход и кандидата
|
||||
env:
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
run: |
|
||||
(cd "$RUNNER_TEMP/ship-review-input" && sha256sum -c manifest.sha256)
|
||||
test "$(git rev-parse HEAD)" = "$CANDIDATE"
|
||||
|
||||
- name: Установить зависимости
|
||||
run: npm ci
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium
|
||||
|
||||
# Тот же обход, что у конвейера (_process.yml, anthropics issue 1817).
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
run: |
|
||||
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
|
||||
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
ver="${ver:-2.1.265}"
|
||||
base=https://downloads.claude.ai/claude-code-releases
|
||||
bin="$HOME/.local/bin/claude"
|
||||
mkdir -p "$(dirname "$bin")"
|
||||
curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin"
|
||||
sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum')
|
||||
echo "$sum $bin" | sha256sum -c -
|
||||
chmod +x "$bin"
|
||||
"$bin" --version
|
||||
echo "path=$bin" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
|
||||
env:
|
||||
REVIEW_DOC: ${{ runner.temp }}/ship-review.md
|
||||
REVIEW_INPUT: ${{ runner.temp }}/ship-review-input
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты ревьюер кода проекта House Plan. Язык ответа — русский.
|
||||
|
||||
Бета: ${{ inputs.tag }} · кандидат ${{ needs.prepare.outputs.candidate }}
|
||||
(рабочая копия уже на нём) · база диапазона: ${{ needs.prepare.outputs.base || 'нет' }}.
|
||||
Задачи: ${{ needs.prepare.outputs.issues }} — вход в $REVIEW_INPUT/brief.md
|
||||
(строка ТЗ каждой задачи и её коммиты) и $REVIEW_INPUT/ship-issues.json.
|
||||
|
||||
Эти задачи шли по треку ship и слиты в dev без ревью модели: правка
|
||||
в механических рамках и зелёный лёгкий Validate. Ты — первый, кто
|
||||
читает их код. Правила — docs/process/REVIEWER.md, раздел
|
||||
«Пакетное ревью ship», канон — PROCESS.md §5 и §11.7. Прочитай их
|
||||
первыми.
|
||||
|
||||
По каждой задаче: `git show <sha>` каждого коммита; делает ли код
|
||||
то, что сказано в строке ТЗ, и только это; не ломает ли соседнее
|
||||
поведение; не вышла ли правка из трека ship по смыслу (новый
|
||||
UX-контракт, геометрия, конфиг, перф) — это находка Medium с
|
||||
предложением трека. Исполняй, где дёшево: `npm test`, смоук,
|
||||
который называет строка ТЗ или выбирает
|
||||
`node scripts/smoke-select.mjs --base <sha>^ --head <sha>`.
|
||||
|
||||
Серьёзность: High — дефект, который увидит пользователь беты, или
|
||||
правка не делает заявленного; High блокирует публикацию беты.
|
||||
Medium — поведение вне заявленного или вне рамок трека; Low —
|
||||
остальное. Находка чинится отдельной задачей, ты ничего не правишь.
|
||||
|
||||
Ты ничего не правишь и не публикуешь: ни код, ни issue, ни
|
||||
комментарии. Права на запись у тебя нет. Любые изменения рабочей
|
||||
копии будут отброшены — после проверок восстанови её сам
|
||||
(`git checkout -- . && git clean -fd`), если что-то менял.
|
||||
|
||||
Напиши документ в файл по пути из переменной REVIEW_DOC (абсолютный,
|
||||
вне репозитория). Первой строкой после заголовка —
|
||||
`Итог: High N · Medium N · Low N`. Затем по задаче на раздел
|
||||
`## #NN — заголовок`: что проверил (команда → результат), находки с
|
||||
воспроизведением, чего не проверял.
|
||||
|
||||
Затем верни JSON по схеме — последнее обязательное действие.
|
||||
claude_args: |
|
||||
--max-turns 150
|
||||
--allowedTools Read,Write,Grep,Glob,Bash
|
||||
--json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}'
|
||||
|
||||
- name: Запечатать результат модели
|
||||
env:
|
||||
SOURCE: ${{ runner.temp }}/ship-review.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
run: |
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
mkdir -p "$dir"
|
||||
printf '%s' "$OUT" > "$dir/result.json"
|
||||
jq -e '(.high|type=="number") and (.medium|type=="number") and (.low|type=="number") and (.summary|type=="string")' \
|
||||
"$dir/result.json" >/dev/null
|
||||
cp "$SOURCE" "$dir/ship-review.md"
|
||||
(cd "$dir" && sha256sum ship-review.md result.json > manifest.sha256)
|
||||
|
||||
- name: Передать результат публикации
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
name: "Ship-ревью: документ в dev"
|
||||
needs: [prepare, model_review]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: ship-review-result-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ship-review-result
|
||||
|
||||
# Модель пишет только текст. Путь, машинный блок (его читает гейт беты),
|
||||
# индекс и коммит решает этот шаг.
|
||||
- name: Опубликовать документ
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
DOC: ${{ needs.prepare.outputs.doc }}
|
||||
CANDIDATE: ${{ needs.prepare.outputs.candidate }}
|
||||
BASE: ${{ needs.prepare.outputs.base }}
|
||||
ISSUES: ${{ needs.prepare.outputs.issues }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/ship-review-result"
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
test "$DOC" = "$(node scripts/ship-review.mjs doc --tag="$TAG")"
|
||||
high=$(jq -r '.high' "$dir/result.json")
|
||||
medium=$(jq -r '.medium' "$dir/result.json")
|
||||
low=$(jq -r '.low' "$dir/result.json")
|
||||
for attempt in 1 2 3; do
|
||||
git fetch -q origin dev
|
||||
git reset -q --hard origin/dev
|
||||
git clean -fdq
|
||||
mkdir -p docs/reviews
|
||||
{
|
||||
cat "$dir/ship-review.md"
|
||||
printf '\n\n'
|
||||
node -e '
|
||||
import("./scripts/ship-review.mjs").then((m) => process.stdout.write(m.anchorBlock({
|
||||
tag: process.env.TAG, candidate: process.env.CANDIDATE, base: process.env.BASE || null,
|
||||
issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number),
|
||||
high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]),
|
||||
runUrl: process.env.RUN_URL,
|
||||
})));
|
||||
' "$high" "$medium" "$low"
|
||||
} > "$DOC"
|
||||
node scripts/reviews-index.mjs --dir=docs/reviews --strict
|
||||
git add -- "$DOC" docs/reviews/INDEX.md
|
||||
git diff --cached --name-only | node scripts/review-doc-guard.mjs
|
||||
git -c user.name="claude[bot]" \
|
||||
-c user.email="209825114+claude[bot]@users.noreply.github.com" \
|
||||
commit -q -F - <<MSG
|
||||
docs: ship review for $TAG
|
||||
|
||||
Пакетное ревью задач track:ship перед бетой (PROCESS.md §11.7).
|
||||
Задачи: $ISSUES. Итог: High $high · Medium $medium · Low $low.
|
||||
|
||||
Issue: #696
|
||||
User-Visible: no
|
||||
MSG
|
||||
git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
|
||||
if git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" HEAD:dev; then
|
||||
echo "### Пакетное ревью ship $TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Задачи $ISSUES · High $high · Medium $medium · Low $low — \`$DOC\` в dev." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::$DOC опубликован: High $high · Medium $medium · Low $low"
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::dev ушёл вперёд — попытка $attempt из 3, документ собирается заново"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
echo "::error::документ ревью не опубликован в dev за три попытки"
|
||||
exit 1
|
||||
@@ -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,17 +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). Гейт стоит
|
||||
@@ -138,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), и тогда его коммиты не судит
|
||||
@@ -218,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() {
|
||||
@@ -229,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"
|
||||
@@ -282,7 +340,8 @@ jobs:
|
||||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||||
# без единого исполненного тяжёлого гейта.
|
||||
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
|
||||
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
|
||||
# `Release:` вне веток задач, #697), по `workflow_dispatch full=true`, на
|
||||
# PR (#479). Логика и
|
||||
# её тест — scripts/classify-changes.mjs.
|
||||
- id: heavy
|
||||
env:
|
||||
@@ -290,6 +349,7 @@ jobs:
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
@@ -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` →
|
||||
@@ -73,13 +75,19 @@ overlap. The committed bundle changes only in a commit with a `Release:` trailer
|
||||
(#657); an ordinary task restores it with `npm run bundle:clean` before
|
||||
committing.
|
||||
|
||||
**Tracks** (`PROCESS.md` §5, §5.1): `small` is the default — the spec lives in the
|
||||
issue body and its review is a comment; taking the full track means naming the
|
||||
`small` criterion the task fails. `trivial` skips the spec stage for a bug whose
|
||||
expected behaviour is already on record. An **infrastructure** task — not a single
|
||||
**Tracks** (`PROCESS.md` §5, §5.1): the label `track:ship`, `track:show` or
|
||||
`track:ask` sets the route, and the owner's label beats the criteria. `show` is the
|
||||
default: up to three AC in the issue body, no spec review, `S2` → `S5`. `ship` is a
|
||||
one-sentence change within fixed limits, `S1` → `S5`. `ask` is the full route with
|
||||
a spec review. Any agent may raise a track with a reason; only the owner lowers it.
|
||||
`small` and `trivial` read as `show`. An **infrastructure** task — not a single
|
||||
class A file — skips analysis and spec and enters at `S7-code-review`
|
||||
(`PROCESS.md` §1). Code review is never skipped on any track: it checks scope,
|
||||
risks and the evidence from executed tests, but does not replace executing them.
|
||||
(`PROCESS.md` §1). Every change is code-reviewed; on `ship` the review moves to a
|
||||
batch review of the beta range before the tag (`ship-review.yml`, `PROCESS.md`
|
||||
§11.7). The review pipeline prices each round by track (§10.4): diff mutants
|
||||
only on `ask` or with `ci:mutants`; a rebase before review only on `ask` or when
|
||||
the branch does not merge cleanly into `dev`. Review checks scope, risks and the
|
||||
evidence from executed tests, but does not replace executing them.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -94,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
|
||||
|
||||
+14
-10
@@ -60,12 +60,15 @@ because the plan canvas and editors cannot be mirrored by translations alone.
|
||||
|
||||
## Documentation screenshots
|
||||
|
||||
The images under `docs/images/` are produced only from synthetic data by the
|
||||
`Docs screenshots` workflow (`demo/docs/capture.mjs` on the pinned Chromium)
|
||||
and accepted locally with `npm run docs:accept -- --reviewed --from=<unpacked
|
||||
artifact>`; when a change cannot move a pixel, `npm run docs:accept --
|
||||
--identical` re-captures locally, compares decoded pixels and refreshes only the
|
||||
source fingerprint. Scenario version, source fingerprint and every image hash
|
||||
The images under `docs/images/` are produced only from synthetic data on the
|
||||
pinned Chromium in CI. A task branch does not commit them, nor the golden
|
||||
baselines: once per beta the `beta-derived.yml` workflow refreshes the
|
||||
fingerprint, the frames and the golden baselines on `dev` in one bot commit,
|
||||
accepting only the frames it was told to expect (`PROCESS.md` §8, #697). A task
|
||||
that changes visuals on purpose sets the `ci:golden` label. The manual path —
|
||||
the `Docs screenshots` workflow and `npm run docs:accept -- --reviewed
|
||||
--from=<unpacked artifact>`, or `--identical` when no pixel can move — stays
|
||||
for the release manager. Scenario version, source fingerprint and every image hash
|
||||
are recorded in the [screenshot index](docs/images/screenshots.json), and
|
||||
`node scripts/check-docs.mjs` reports a stale fingerprint: a warning on an
|
||||
ordinary push, an error on a beta candidate (a commit with a `Release:`
|
||||
@@ -91,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)
|
||||
@@ -124,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
|
||||
|
||||
+297
-136
@@ -3,12 +3,14 @@
|
||||
> **Статус документа: канон** (редакция 2026-08-13, ролевое уточнение
|
||||
> 2026-09-13). Решения владельца, на
|
||||
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
|
||||
> имена английские · лёгкий трек **включён** · автор и ревьюер — независимые
|
||||
> имена английские · треки `ship`/`show`/`ask` задаёт метка владельца (#695) · автор и ревьюер — независимые
|
||||
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
|
||||
> входят в общий флоу сразу на `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/*`
|
||||
@@ -93,8 +96,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
|
||||
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на треке `show` 2
|
||||
трек `show` (§5) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
трек `ship` (§5) идёт S1-new → S5-ready, минуя S2, S3 и S4
|
||||
инфраструктурный трек (§1): без S → S7-code-review ⟲ S6-in-progress → S8-merged
|
||||
```
|
||||
|
||||
@@ -125,9 +129,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
5. приоритет **P1/P2/P3**;
|
||||
6. тип: баг / фича / техдолг;
|
||||
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
|
||||
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
|
||||
называется критерий §5, который она не проходит: «обычный трек» без
|
||||
названного критерия обоснованием не является.
|
||||
8. трек — метка `track:ship`, `track:show` или `track:ask` (§5), **по
|
||||
умолчанию `track:show`**. Для `track:ask` называется критерий §5, которого
|
||||
задача не проходит; `track:ship` предлагается, когда правка описывается
|
||||
одним предложением. Метка владельца главнее предложения аналитика.
|
||||
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
|
||||
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
|
||||
согласие**, несогласие он выражает правкой меток или комментарием, и это не
|
||||
@@ -139,7 +144,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
|
||||
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
|
||||
него в ТЗ пишется блок принятых предположений.
|
||||
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
- **Выход:** на `track:ask` — `S3-spec`, на `track:show` — `S5-ready` после AC в
|
||||
теле issue (§5); переход выполняет сам аналитик, не дожидаясь ответа.
|
||||
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
|
||||
это единственный случай, когда аналитика останавливается и ждёт владельца.
|
||||
|
||||
@@ -159,7 +165,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
|
||||
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
|
||||
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
|
||||
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
|
||||
зелёный / жёлтый / красный. Ревью ТЗ проходит только трек `ask` (§5).
|
||||
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
|
||||
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
|
||||
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
|
||||
@@ -174,7 +180,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
|
||||
Все пункты обязательны:
|
||||
|
||||
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
|
||||
- ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте.
|
||||
На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом
|
||||
«нет»: критерии трека их исключают, иначе это `track:ask`;
|
||||
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
|
||||
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
|
||||
- перечислены затронутые файлы и модули;
|
||||
@@ -196,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`, получает свою проверку здесь же.
|
||||
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
|
||||
@@ -246,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): там
|
||||
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
|
||||
достаточно прогона со снятой защитой, приведённого в документе.
|
||||
@@ -256,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 без названного свидетеля**, а не мутанты на
|
||||
@@ -294,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`
|
||||
@@ -380,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` с датой и ссылкой на источник.
|
||||
@@ -434,8 +455,10 @@ dev, ни при публикации документа код-ревью: ин
|
||||
|
||||
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
|
||||
разработке» или дальше.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
|
||||
пронумерованных AC с указанием доказательства и назначенного исполнителя.
|
||||
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ в объёме трека
|
||||
(§5: на `track:ask` — с зелёным ревью ТЗ; на `show` — до трёх AC; на `ship` —
|
||||
строка «что меняется и чем проверить»), доказательства для каждого AC и
|
||||
назначенного исполнителя. Инфраструктурная задача ТЗ не пишет (§1).
|
||||
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
|
||||
комментарием с именем ветки. У одного исполнителя одновременно не более одного
|
||||
issue в разработке.
|
||||
@@ -455,20 +478,24 @@ 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 и машинно-проверяемым паспортом. Второй
|
||||
путь убирает только первый ожидаемо красный CI-прогон; полный GitHub Validate
|
||||
на точном SHA коммита с эталонами остаётся обязательным. Принятие ради
|
||||
зелёного CI — нарушение процесса.
|
||||
зелёного CI — нарушение процесса. Где принимаются эталоны — §8: на `dev`
|
||||
одним коммитом на бету, в задаче — только с меткой `ci:golden` (#697).
|
||||
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
|
||||
раньше, не «по факту наличия кода», не исполнителем.
|
||||
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
|
||||
@@ -501,8 +528,9 @@ dev, ни при публикации документа код-ревью: ин
|
||||
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
|
||||
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
|
||||
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
|
||||
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
|
||||
циклов (шесть документов = четыре цикла плюс два ребейза).
|
||||
чем циклов, поэтому порог проверки №7 в `scripts/process-gate.mjs`
|
||||
(`REVIEW_DOC_LIMIT` — шесть документов одного вида на issue) выше лимита
|
||||
циклов: четыре цикла плюс два ребейза.
|
||||
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
|
||||
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
|
||||
лимита, конвейер сообщает пересчёт, но метку не трогает.
|
||||
@@ -516,90 +544,90 @@ dev, ни при публикации документа код-ревью: ин
|
||||
- **Граница между «циклом» и «новым багом»:** до закрытия беты находка ревьюера —
|
||||
возврат на правки; после закрытия — новый issue. Иначе лимит 4 обходится
|
||||
заведением issue вместо возврата.
|
||||
- Для лёгкого трека лимит ревью ТЗ — **2** цикла: задача на три часа, которую
|
||||
переписывают трижды, лёгкой не была.
|
||||
- На треке `show` лимит код-ревью — **2** цикла, ревью ТЗ на нём нет (§5):
|
||||
задача на три часа, которую переписывают трижды, лёгкой не была.
|
||||
|
||||
---
|
||||
|
||||
## 5. Лёгкий трек (метка `small`) — путь по умолчанию
|
||||
## 5. Треки `ship`, `show`, `ask` — метка владельца
|
||||
|
||||
Умолчание изменено решением владельца 2026-08-27, issue #338. Прежде полный трек
|
||||
был бесплатен, а выбор лёгкого требовал обоснования. Фактическая цена: **2.9
|
||||
ревью-документа на задачу** в среднем и до шести на одну issue (#329, #316,
|
||||
#290) — при том что Medium-находки всё равно чинятся в той же задаче, без
|
||||
отдельного цикла.
|
||||
Решение владельца 2026-09-28, issue #695. Разбор 85 закрытых задач #600–#691
|
||||
показал, что прежний лёгкий трек (`small`) стоил почти столько же, сколько
|
||||
полный: медиана 115 мин и 12 событий против 102 мин и 13. Дешевле был только
|
||||
короткий (`trivial`) — 41 мин и 5 событий. Трек определялся формальными
|
||||
критериями, и у владельца не было метки, чтобы задать его самому.
|
||||
|
||||
**Порог не изменился.** Критерии ниже те же и по-прежнему обязательны все
|
||||
одновременно. Изменилась сторона доказательства: теперь обосновывается не выбор
|
||||
лёгкого трека, а отказ от него — в `S2-analysis` называется критерий, который
|
||||
задача не проходит. Полный трек остаётся тем, чем был, для геометрии, миграций
|
||||
конфига и публичных контрактов: там критерии нарушаются сами, и назвать
|
||||
нарушенный несложно.
|
||||
**Трек задаёт метка `track:ship`, `track:show` или `track:ask`. Метка владельца
|
||||
главнее критериев:** критерии ниже — подсказка аналитика, а не приговор.
|
||||
|
||||
Инверсия умолчания не отменяет ничего из §5 ниже и ничего из §4: бюджет четырёх
|
||||
циклов, арбитраж владельца, обязательность ТЗ на полном треке и правило «ревью
|
||||
до мержа» остаются как были. Меняется только стоимость пути по умолчанию.
|
||||
- Аналитик предлагает трек в «Оценке» (§7.2) и ставит метку. По умолчанию —
|
||||
`track:show`.
|
||||
- Владелец ставит или меняет любую из трёх меток в любой момент; его метка
|
||||
окончательна.
|
||||
- Повысить трек (`ship` → `show` → `ask`) вправе любой агент, с причиной в
|
||||
комментарии. Понизить — только владелец.
|
||||
- Трек пересматривается, когда владелец снял усложнявший пункт (#688):
|
||||
аналитик предлагает понижение, решает владелец.
|
||||
|
||||
**Критерии — все одновременно; нарушенный называется явно:**
|
||||
| | `track:ship` | `track:show` | `track:ask` |
|
||||
|---|---|---|---|
|
||||
| Для чего | документация, текст, очевидная правка в несколько строк, CSS-мелочь | баг и полировка в рамках описанного поведения; инфраструктура (§1) по умолчанию | геометрия, миграции конфига, публичные контракты, перф и touch, новый UX-контракт |
|
||||
| Маршрут | `S1-new` → `S5-ready` → `S6` → `S7` → `S8` | `S1-new` → `S2-analysis` → `S5-ready` → `S6` → `S7` → `S8` | полный, §2 |
|
||||
| ТЗ | строка «что меняется и чем проверить» в теле issue под `## ТЗ` | «Оценка» (§7.2) и до трёх AC в теле issue под `## ТЗ` | полное ТЗ по §7.1 |
|
||||
| Ревью ТЗ | нет | нет | да, лимит 4 цикла |
|
||||
| Локальный гейт | `npm run gate:small` | `npm run gate:small` плюс смоуки `smoke-select` и AC | §8 |
|
||||
| Код-ревью | до слияния нет; пакетное ревью диапазона перед бетой | модель: корректность и AC | документ ревью, как в §2.7 |
|
||||
| Лимит циклов код-ревью | — | 2 | 4 |
|
||||
|
||||
**Рамки `track:ship`** механические: дифф `src/**` не больше 30 строк, без новых
|
||||
файлов в `src/**`, без ключей i18n, без полей конфига и без Python. Выход за
|
||||
рамки переводит задачу в `track:show`. Рамки и слияние `ship` без ревью модели
|
||||
проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью
|
||||
диапазона перед бетой (§11.7).
|
||||
|
||||
**Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу:
|
||||
|
||||
- сложность и риск ≤ 3;
|
||||
- одна поверхность (один диалог, один модуль, один эндпоинт);
|
||||
- нет миграции конфига и новых compatibility-полей;
|
||||
- нет нового UX-контракта — меняется поведение в рамках уже описанного;
|
||||
- нет влияния на производительность и на touch-контракт.
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
|
||||
(в теле issue, как и на полном треке с 2026-09-10);
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
|
||||
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается:
|
||||
оно проверяет скоуп, риски и качество доказательств, но не заменяет исполнение
|
||||
тестов. Единственное исключение из повторного ревью — починка упавшего
|
||||
предрелизного гейта, §11.4.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
полное ТЗ в теле issue по §7.1. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
Решение владельца 2026-08-13, issue #128. Лёгкий трек делает ТЗ дешёвым; короткий
|
||||
обходится без него совсем.
|
||||
|
||||
**Маршрут:** `S1-new` → `S2-analysis` → `S5-ready` → `S6-in-progress` →
|
||||
`S7-code-review` → `S8-merged`. Стадии `S3-spec` и `S4-spec-review` пропускаются.
|
||||
|
||||
`S2-analysis` остаётся: это комментарий, а не прогон CI, и именно там владелец
|
||||
решает приоритет и ценность. AC пишет автор в теле issue при переводе в
|
||||
`S5-ready` — до перехода, иначе ревьюеру нечего будет сверять.
|
||||
|
||||
**Критерии, все обязательны:**
|
||||
|
||||
- тип `bug`;
|
||||
- правка ограничена одной поверхностью, нового UX-контракта нет;
|
||||
- нет миграции конфига, новых ключей i18n, влияния на перф и touch;
|
||||
- AC выражаются тремя проверяемыми утверждениями или меньше;
|
||||
- нет влияния на производительность и на touch-контракт;
|
||||
- **ожидаемое поведение уже зафиксировано** — в `docs/USER-GUIDE.ru.md`, в
|
||||
каноническом документе подсистемы либо однозначно в самом отчёте. Решать нечего.
|
||||
Если есть что решать, это `S3-spec`, и никакая экономия этого не отменяет.
|
||||
|
||||
Метка ставится в `S2-analysis` вместе с остальными оценками, одним комментарием,
|
||||
где владелец утверждает и приоритет.
|
||||
Невыполненный пункт — повод предложить `track:ask` с названным критерием.
|
||||
`track:ship` — когда правку можно описать одним предложением и она укладывается
|
||||
в рамки выше.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры, changelog и **код-ревью**.
|
||||
Лимит циклов код-ревью — 2, как на лёгком треке.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен, метка снимается и issue уходит в
|
||||
`S3-spec` за нормальным ТЗ. Как и на лёгком треке, это не провал, а ранняя
|
||||
**Чем опасны `show` и `ship`.** Они убирают место, где решение проверялось до
|
||||
написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение. Если по
|
||||
ходу выясняется, что решать есть что, трек повышается до `ask`: issue уходит в
|
||||
`S3-spec` и получает полное ТЗ в теле issue по §7.1. Это не провал, а ранняя
|
||||
диагностика.
|
||||
|
||||
**Чем этот трек опасен.** Он убирает единственное место, где решение проверялось
|
||||
до написания кода. Признак «решать нечего» держит всю конструкцию, и его нельзя
|
||||
подтверждать ощущением — только ссылкой на уже зафиксированное поведение.
|
||||
**Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры
|
||||
коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега
|
||||
беты (§8), гейты стабильного релиза. Качество держится на `dev` и на кандидате
|
||||
беты, а не на каждой ветке.
|
||||
|
||||
### 5.1 Метки тяжёлых проверок и прежние метки
|
||||
|
||||
Тяжёлые проверки заказываются метками на любом треке — ставит владелец или
|
||||
автор с причиной в комментарии:
|
||||
|
||||
| Метка | Что включает |
|
||||
|---|---|
|
||||
| `ci:full` | полный Validate на ветке задачи |
|
||||
| `ci:golden` | golden на ветке и приёмку сдвинутых кадров в самой задаче |
|
||||
| `ci:mutants` | мутанты по диффу на кандидате ревью и слияния |
|
||||
|
||||
Как конвейер читает метки, описывает §10.4: `ci:mutants` (#696), `ci:full` и
|
||||
`ci:golden` — dispatch Validate с `full=true` на материале ревью (#697).
|
||||
|
||||
**Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая
|
||||
задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без
|
||||
трековой метки — как `track:show`. Новые задачи получают только `track:*`.
|
||||
|
||||
---
|
||||
|
||||
@@ -613,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 | добавлять код в релизный коммит |
|
||||
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
|
||||
@@ -643,7 +671,7 @@ dev, ни при публикации документа код-ревью: ин
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (только `track:ask`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
↔ ревью кода docs/reviews/CODE-REVIEW-<tag|NN>-rN.md
|
||||
@@ -695,7 +723,7 @@ issue #NN
|
||||
Короткие и однообразные, чтобы читались и человеком, и машиной.
|
||||
|
||||
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
|
||||
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
|
||||
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
|
||||
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
|
||||
@@ -718,9 +746,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
|
||||
@@ -729,8 +764,7 @@ npm run build && node scripts/bundle-policy.mjs --verify HEAD
|
||||
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
|
||||
node demo/smoke_<целевые>.mjs
|
||||
node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый код не добавляет any
|
||||
npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
npm run golden:verify # только с меткой ci:golden (#697)
|
||||
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
|
||||
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд
|
||||
npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
@@ -750,24 +784,26 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
комментарии, строке или идентификаторе ложных срабатываний не даёт.
|
||||
|
||||
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
|
||||
`typecheck`, `npm test`, `npm run build` с `bundle-policy --verify` (копии сверяются на кандидате, #657), а при
|
||||
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
|
||||
`typecheck`, `npm test`, `npm run build` с `bundle-policy --verify` (копии сверяются на кандидате, #657).
|
||||
Свежесть скриншотов документации — не гейт задачи (#697, ниже). По
|
||||
необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их —
|
||||
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
|
||||
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
|
||||
диффу, печатает
|
||||
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при изменении видимого
|
||||
результата, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
|
||||
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
|
||||
|
||||
Скриншоты снимаются **только** джобой `Docs screenshots` (`workflow_dispatch`) и
|
||||
принимаются локально: `npm run docs:accept -- --reviewed --from=<распакованный
|
||||
артефакт>` (#246). Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
|
||||
Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml`
|
||||
(съёмка и приёмка одним коммитом бота на `dev`, #697) или `Docs screenshots`
|
||||
(`workflow_dispatch`, только артефакт) с приёмкой вручную: `npm run docs:accept --
|
||||
--reviewed --from=<распакованный артефакт>` (#246). Ручной путь остаётся
|
||||
релиз-менеджеру, если бот недоступен. Съёмка на своей машине даёт байтово другой PNG при том же
|
||||
кадре, и набор из «не того» браузера переписывает все кадры (их число — в
|
||||
`demo/docs/screenshots.mjs`) без единого
|
||||
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота
|
||||
проверяет релиз-менеджер, коммит ручной приёмки делает человек.
|
||||
|
||||
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
|
||||
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
|
||||
@@ -776,13 +812,48 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
|
||||
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
|
||||
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
|
||||
при следующей задаче (#237). Пересъёмка — по двум абзацам выше, коммит
|
||||
вместе с задачей.
|
||||
**Производные артефакты — на `dev`, один коммит на бету** (#697, решение
|
||||
владельца 2026-09-28). Отпечаток скриншотов документации считается по всему
|
||||
`src/**`, поэтому любая правка фронтенда делает его устаревшим. Пока его
|
||||
коммитила каждая задача, `docs/images/screenshots.json` правили 70 раз за 14 дней,
|
||||
и две параллельные задачи конфликтовали на нём гарантированно. Golden
|
||||
оплачивала следующая задача: сдвиг, влитый одной, всплывал у другой (#687 →
|
||||
#685, #688 → #689). Теперь:
|
||||
|
||||
- **ветки задач не коммитят** `docs/images/**` и `demo/golden/baselines/**`. На
|
||||
ветке свежесть скриншотов — предупреждение preflight, а golden не идёт;
|
||||
- **перед кандидатом беты** `beta-derived.yml` одним коммитом бота обновляет на
|
||||
`dev` отпечаток и кадры (съёмка тем же каноном, приёмка `docs:accept
|
||||
--reviewed`) и эталоны golden из артефакта полного Validate на `dev`
|
||||
(`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите).
|
||||
Изменившийся кадр или сцена принимается, только если назван во входах
|
||||
workflow; необъявленная разница — отказ с перечнем. Коммит проверяет
|
||||
релиз-менеджер;
|
||||
- **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер
|
||||
прогоняет полный набор на материале ревью, и сдвинутые кадры задача
|
||||
принимает сама — по §3 п.13;
|
||||
- **`Release:` на ветке задачи тяжёлый набор не включает** (`classify-changes.mjs`):
|
||||
тяжёлое запускают `ci:full` и `ci:golden`, трейлер `Release:` на `dev` и ночной
|
||||
прогон.
|
||||
|
||||
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
|
||||
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
|
||||
|
||||
**Храповики — полоса над потолком беты** (#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`,
|
||||
@@ -850,8 +921,10 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
|
||||
| `rejected` | Отклонено, issue закрыт |
|
||||
|
||||
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
|
||||
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
|
||||
Модификаторы: трек `track:ship`/`track:show`/`track:ask` (§5), тяжёлые проверки
|
||||
`ci:full`/`ci:golden`/`ci:mutants` (§5.1), `hotfix`, `process`, `review-4`;
|
||||
приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`. Прежние `small` и
|
||||
`trivial` читаются как `track:show` (§5.1).
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
@@ -902,7 +975,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:<хеш аттестации>`. Локальный хеш обязан
|
||||
@@ -955,7 +1029,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:
|
||||
|
||||
@@ -976,6 +1052,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`.
|
||||
@@ -1062,9 +1143,46 @@ 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). Трек
|
||||
снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и
|
||||
диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1:
|
||||
инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`.
|
||||
|
||||
| | `ship` | `show` | `ask` |
|
||||
|---|---|---|---|
|
||||
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | с мутантами по диффу, dispatch |
|
||||
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
|
||||
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
|
||||
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | dispatch с мутантами |
|
||||
|
||||
Метка `ci:mutants` возвращает мутанты по диффу на любом треке. Рамки `ship`
|
||||
(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена
|
||||
`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue
|
||||
комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя
|
||||
не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно
|
||||
применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
|
||||
|
||||
В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью
|
||||
кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там.
|
||||
Ориентир — данные #695: `small` стоил медиану 115 минут и 12 событий, `trivial` —
|
||||
41 минуту и 5 событий; `show` целится в уровень `trivial`.
|
||||
|
||||
**Ревью `show` судит корректность и AC.** Medium — дефект поведения, который
|
||||
увидит пользователь, или невыполненный AC. Бухгалтерия — нет мутанта или записи
|
||||
в реестре, нечувствительный тест на побочный вызов, формулировка в документе —
|
||||
Low и цикла не открывает. Отсутствие мутантов по диффу на `show` не находка:
|
||||
полный реестр гоняется ночью. Ревью `show` не ставит Chromium, если тело issue
|
||||
не называет смоук или браузер. Ревью ТЗ не ставит ни `npm ci`, ни браузер: кода
|
||||
оно не исполняет.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
|
||||
@@ -1138,7 +1256,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
|
||||
ошибки.
|
||||
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
|
||||
**Ветка приводится к `dev` до ревью, а не после** (#257) — на треке `ask`. `show`
|
||||
и `ship` с чистым слиянием ребейзятся один раз, при слиянии (#696, выше). Раньше ревью читало ветку
|
||||
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
|
||||
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
|
||||
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
|
||||
@@ -1173,10 +1292,16 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
|
||||
вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и
|
||||
лёгкий Validate кандидата уже запустил сам push в ветку: слияние ждёт этот
|
||||
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
|
||||
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
|
||||
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
|
||||
прилетевший после слияния, её сохраняет, и комментарий об этом говорит.
|
||||
Влитая ветка никому не нужна, а агент, ищущий ветку по номеру, иначе может
|
||||
взять устаревшую.
|
||||
|
||||
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
|
||||
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
|
||||
@@ -1230,9 +1355,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
|
||||
## 11. Исключения
|
||||
|
||||
### 11.1 Лёгкий трек
|
||||
### 11.1 Треки
|
||||
|
||||
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
|
||||
См. §5 — треки `ship` и `show` не исключения из правила №1, а более дешёвые пути
|
||||
по тем же статусам.
|
||||
|
||||
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
|
||||
|
||||
@@ -1275,7 +1401,10 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
- эталоны golden принимаются только через `npm run golden:accept -- --reviewed`
|
||||
на полном Linux-артефакте GitHub CI либо полном аттестованном WSL-артефакте;
|
||||
после локальной приёмки полный GitHub Validate на точном финальном SHA всё
|
||||
равно обязателен. «Чтобы гейт позеленел» основанием не является.
|
||||
равно обязателен. «Чтобы гейт позеленел» основанием не является. Сдвиг, который
|
||||
влили задачи линии, принимается на `dev` одним коммитом на бету —
|
||||
`beta-derived.yml` с объявленными сценами (§8, #697), а не задачей, которая
|
||||
наткнулась на него следующей.
|
||||
|
||||
**Границы, за которыми исключение не действует.** Оно про починку названного
|
||||
гейтом дефекта, а не про продолжение разработки под видом починки. Правка идёт
|
||||
@@ -1295,8 +1424,10 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
|
||||
выпуском.
|
||||
|
||||
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
|
||||
единственное, и относится только к окну между `S8-merged` и выпуском.
|
||||
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
|
||||
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
|
||||
на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
|
||||
окну между `S8-merged` и выпуском.
|
||||
|
||||
### 11.5 Независимое ревью линии перед стабильным релизом
|
||||
|
||||
@@ -1360,6 +1491,36 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
выпуск после такого отказа, исправляют причину и получают новый совместимый
|
||||
полный зелёный прогон на том же финальном SHA либо на новом SHA кандидата.
|
||||
|
||||
### 11.7 Пакетное ревью `ship` перед бетой
|
||||
|
||||
Решение владельца 2026-09-28, issue #696.
|
||||
|
||||
**Зачем.** `track:ship` сливается без ревью модели (§5, §10.4): правка в
|
||||
механических рамках и зелёный лёгкий Validate. Прочитать её код обязан кто-то
|
||||
до того, как она уйдёт пользователям беты. Одна сессия на все ship-задачи
|
||||
диапазона дешевле ревью на каждую, а рамки ship держат объём малым.
|
||||
|
||||
**Шаг.** Перед публикацией беты — `ship-review.yml`
|
||||
(`gh workflow run ship-review.yml --ref dev -f tag=vX.Y.Z-beta.N`):
|
||||
|
||||
- **вход** — issue из трейлеров `Issue: #NN` в диапазоне «прошлый тег..кандидат»
|
||||
(тот же построитель, что `RELEASE-MEMBERSHIP.json`, #547), из них — ship: с
|
||||
маркером `hp:ship-merge` в комментариях или с меткой `track:ship`. Собирает
|
||||
`scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается;
|
||||
- **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код
|
||||
заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по
|
||||
смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»;
|
||||
- **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком:
|
||||
задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует
|
||||
детерминированный шаг.
|
||||
|
||||
**Гейт беты.** `ship-review.mjs check` стоит в обоих путях публикации —
|
||||
`publish-prerelease.yml` и `npm run release:prerelease` (включая `--check`).
|
||||
Если в диапазоне есть ship-задачи, документ обязан лежать в кандидате или в
|
||||
`dev`, покрывать их все и не нести High. Задача, слитая после ревью, требует
|
||||
пересъёмки (`-f force=true`). High чинится отдельной задачей, затем ревью
|
||||
переснимается. Medium и Low решает владелец, как в §11.5.
|
||||
|
||||
---
|
||||
|
||||
## 12. Запрещено
|
||||
|
||||
+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
-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
|
||||
|
||||
|
||||
+26
-14
@@ -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`.
|
||||
@@ -439,14 +448,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 +748,8 @@ See `demo/performance/README.md` for commands and the budget-review contract.
|
||||
Остальные пункты с пометкой `[manual]` описывали обычное поведение режимов,
|
||||
редакторов, диалогов, правил значков и `houseplan-space-card`. Для них нужен
|
||||
смок, а не ручная проверка: задача, которая трогает такую поверхность без
|
||||
смока, пишет его в том же коммите (правило `[auto: …]` выше). Пункты
|
||||
`[manual]` разделов ниже остаются как есть.
|
||||
смока, пишет его в том же коммите (правило `[auto: …]` выше). Оставшиеся пункты
|
||||
`[manual]` разделов выше остаются как есть.
|
||||
|
||||
## Release regression quickies
|
||||
|
||||
|
||||
+50
-34
@@ -40,21 +40,28 @@
|
||||
- Чек-лист комментарием: дубликаты, скоуп по `docs/SCOPE.md` и
|
||||
`docs/TOUCH-SUPPORT.md`, ценность, сложность и риск, приоритет, тип,
|
||||
поверхности, трек. Оценки ставятся метками сразу; молчание владельца —
|
||||
согласие; в `S3-spec` аналитик переводит сам. Останавливается аналитика
|
||||
согласие; дальше аналитик переводит сам: `track:ask` — в `S3-spec`,
|
||||
`track:show` — в `S5-ready`. Останавливается аналитика
|
||||
только на конфликте со `SCOPE.md`
|
||||
([§2.2](../../PROCESS.md#22-аналитика-и-оценка)).
|
||||
- Шаблон: `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
|
||||
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
|
||||
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Лёгкий трек `small` — путь по умолчанию: обосновывается не выбор лёгкого
|
||||
трека, а отказ от него — называется нарушенный критерий. Критерии, все
|
||||
сразу: сложность и риск ≤ 3; одна поверхность; нет миграции конфига;
|
||||
нет нового UX-контракта; нет влияния на перф и touch
|
||||
([§5](../../PROCESS.md#5-лёгкий-трек-метка-small--путь-по-умолчанию)).
|
||||
- Короткий трек `trivial`: `S2-analysis` → `S5-ready`, AC автор пишет в теле
|
||||
issue до перехода. Тип `bug`, одна поверхность, без i18n, миграции, перфа и
|
||||
touch, не больше трёх AC, и ожидаемое поведение уже зафиксировано — решать
|
||||
нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).
|
||||
- Трек задаёт метка `track:ship`, `track:show` или `track:ask`, по умолчанию
|
||||
`track:show`. Метка владельца главнее критериев; повысить трек может любой
|
||||
агент с причиной в комментарии, понизить — только владелец
|
||||
([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- `track:show`: `S2-analysis` → `S5-ready`, до трёх AC автор пишет в теле
|
||||
issue до перехода; ревью ТЗ нет, лимит код-ревью 2. Уместен, когда всё
|
||||
сразу: сложность и риск ≤ 3, одна поверхность, нет миграции, нового
|
||||
UX-контракта, влияния на перф и touch, и ожидаемое поведение уже
|
||||
зафиксировано — решать нечего ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- `track:ship`: `S1-new` → `S5-ready`, в теле issue строка «что меняется и чем
|
||||
проверить». Рамки: дифф `src/**` до 30 строк, без новых файлов, i18n, полей
|
||||
конфига и Python ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Тяжёлые проверки на любом треке — метками `ci:full`, `ci:golden`,
|
||||
`ci:mutants`; прежние `small` и `trivial` читаются как
|
||||
`track:show` ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)).
|
||||
|
||||
## ТЗ (`S3-spec`)
|
||||
|
||||
@@ -63,9 +70,9 @@
|
||||
- Обязательные разделы: сценарий · что человек увидит до и после · проблема ·
|
||||
скоуп и не-скоуп · контракт поведения · UX · модель данных и миграция ·
|
||||
i18n · AC1…ACn с доказательством · план автотестов · риски · откат ·
|
||||
release-артефакты. На лёгком треке короче: проблема · контракт · AC · откат
|
||||
([§7.1](../../PROCESS.md#71-цепочка),
|
||||
[§5](../../PROCESS.md#5-лёгкий-трек-метка-small--путь-по-умолчанию)).
|
||||
release-артефакты. На `track:show` — до трёх AC, на `track:ship` — одна
|
||||
строка ([§7.1](../../PROCESS.md#71-цепочка),
|
||||
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Размытое место не додумывается. Владельцу задаются только продуктовые
|
||||
вопросы — что человек видит или делает и какой объём видимых изменений
|
||||
входит в issue. Всё, чего пользователь не наблюдает, автор решает сам и
|
||||
@@ -74,25 +81,27 @@
|
||||
- Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа ·
|
||||
вариант по умолчанию. Пока ждём ответа, issue остаётся в `S3-spec` и
|
||||
получает `blocked` ([§7.1](../../PROCESS.md#71-цепочка)).
|
||||
- DoR перед `S5-ready`: зелёное ревью ТЗ; пронумерованные AC со способом
|
||||
- DoR перед `S5-ready`: на `track:ask` зелёное ревью ТЗ; пронумерованные AC со способом
|
||||
доказательства (`unit` / `backend` / `smoke` / `golden` / «ревью кода»);
|
||||
файлы и модули; ключи i18n en + ru; миграция по
|
||||
`docs/CONFIG-COMPATIBILITY.md`; перф; touch; release-артефакты; откат; нет
|
||||
открытых продуктовых вопросов
|
||||
([§2.5](../../PROCESS.md#25-готово-к-разработке-dor)).
|
||||
- Лимит — 4 цикла ревью, на лёгком и коротком треке 2; зелёный вердикт цикла
|
||||
открытых продуктовых вопросов. На `ship` и `show` пункты DoR закрываются
|
||||
словом «нет» ([§2.5](../../PROCESS.md#25-готово-к-разработке-dor)).
|
||||
- Лимит — 4 цикла ревью, на `track:show` 2 цикла код-ревью; зелёный вердикт цикла
|
||||
не тратит; исчерпание — решение владельца: разделить, отклонить, арбитраж
|
||||
([§4](../../PROCESS.md#4-лимит-циклов-ревью-4)).
|
||||
|
||||
## Реализация (`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` /
|
||||
@@ -130,21 +139,22 @@
|
||||
|
||||
## Гейты перед хендоффом
|
||||
|
||||
- Минимальный набор по изменённым поверхностям: `npx tsc --noEmit`,
|
||||
`npm test`, `npm run build` + `bundle-policy --verify`, `smoke-select` и
|
||||
целевые смоки, `no-new-any`; по диффу — `golden:verify`, `check-docs`,
|
||||
`model-invariants`, `pytest tests_backend`, junction parity. Команды —
|
||||
в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает
|
||||
обязательную часть (`docs/TESTING.md`, «Локальный набор перед пушем»).
|
||||
- Обязательная часть — `npm run gate:small`: его состав живёт в
|
||||
`scripts/gate-small.mjs` и нигде не переписывается. По диффу и AC сверх
|
||||
него — целевые смоки из вывода `smoke-select`, `model-invariants`,
|
||||
`pytest tests_backend`, junction parity; `golden:verify` — только с меткой
|
||||
`ci:golden` ([§8](../../PROCESS.md#8-гейты); `docs/TESTING.md`, «Локальный
|
||||
набор перед пушем»).
|
||||
- Бандл в коммит задачи не идёт: сборка переписывает отслеживаемый `dist/`,
|
||||
перед коммитом — `npm run bundle:clean`; хук `commit-msg` отклоняет пути
|
||||
бандла без трейлера `Release:` (#657, [§1](../../PROCESS.md#1-основное-правило)).
|
||||
- Новый код не добавляет `any`: гейт судит добавленные строки; исключение —
|
||||
`// any-ok: <конкретная причина>` на той же строке
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
- Любая правка `src/**` требует `node scripts/check-docs.mjs`: отпечаток
|
||||
скриншотов считается по всему фронтенду. Скриншоты снимает только CI;
|
||||
без изменения кадров — `npm run docs:accept -- --identical`
|
||||
- Ветка задачи не коммитит `docs/images/**` и `demo/golden/baselines/**`:
|
||||
отпечаток и кадры скриншотов, эталоны golden обновляет один коммит бота на
|
||||
`dev` перед бетой. Задача, которая меняет визуал намеренно, ставит
|
||||
`ci:golden` и принимает сдвинутые кадры сама
|
||||
([§8](../../PROCESS.md#8-гейты)).
|
||||
- Полные наборы — предрелизный гейт, а не гейт ревью. Упавший предрелизный
|
||||
гейт автор чинит и повторно прогоняет; повторного код-ревью нет, если
|
||||
@@ -164,11 +174,17 @@
|
||||
`node scripts/process-gate.mjs --issues`; после `S7-code-review` в ветку не
|
||||
пушить до вердикта; `S7` ставится один раз на заход
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ревью не начинается на красном коде: конвейер сам гоняет Validate с
|
||||
мутантами и возвращает красный в `S6-in-progress` без траты цикла
|
||||
- Ревью не начинается на красном коде: конвейер сам гоняет Validate — с
|
||||
мутантами на `ask`, лёгкий на `show`/`ship` — и возвращает красный в
|
||||
`S6-in-progress` без траты цикла
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ветка приводится к `dev` до ревью, а не после: конфликт — возврат в
|
||||
`S6-in-progress` до ревью ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
`S6-in-progress` до ревью; `show`/`ship` с чистым слиянием ребейзятся один
|
||||
раз, при слиянии ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- `ship` в рамках сливается без ревью модели; выход за рамки конвейер сам
|
||||
переводит в `track:show`. Код `ship` читает пакетное ревью перед бетой
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер),
|
||||
[§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
- Автор обязан дождаться вердикта, а не заканчивать сессию:
|
||||
`node scripts/wait-verdict.mjs --issue NN`, смотреть на метку, а не на
|
||||
комментарий; при `blocked` не ждать. После прогона ревью метка меняется
|
||||
|
||||
+39
-11
@@ -24,8 +24,8 @@
|
||||
|
||||
## Ревью ТЗ
|
||||
|
||||
- Артефакт — `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, на лёгком треке —
|
||||
комментарий ([§2.4](../../PROCESS.md#24-тз-на-ревью)).
|
||||
- Артефакт — `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`. Ревью ТЗ проходит только
|
||||
трек `ask` ([§2.4](../../PROCESS.md#24-тз-на-ревью)).
|
||||
- ТЗ живёт в теле issue, раздел `## ТЗ`; `docs/specs/` — архив до 2026-09-10
|
||||
([§2.3](../../PROCESS.md#23-тз-в-работе--написание-тз)).
|
||||
- Проверить обязательные разделы, однозначность каждого AC и способ его
|
||||
@@ -71,20 +71,22 @@
|
||||
|
||||
## Объём гейтов
|
||||
|
||||
- Всегда: `typecheck`, `npm test`, `npm run build` + `bundle-policy --verify` (копии сверяются только на кандидате, #657); при
|
||||
диффе по `src/**` — ещё `node scripts/check-docs.mjs`. Зелёный Validate на
|
||||
SHA материала подтверждает дешёвые гейты ([§8](../../PROCESS.md#8-гейты)).
|
||||
- Всегда: `typecheck`, `npm test`, `npm run build` + `bundle-policy --verify` (копии сверяются только на кандидате, #657).
|
||||
Свежесть скриншотов документации — не гейт задачи: отпечаток обновляет
|
||||
коммит бота на `dev` перед бетой. Зелёный Validate на SHA материала
|
||||
подтверждает дешёвые гейты ([§8](../../PROCESS.md#8-гейты)).
|
||||
- По диффу и AC: смоки — названные в AC плюс вывод
|
||||
`node scripts/smoke-select.mjs --base <base> --head <head>` с решением по
|
||||
каждой строке; `golden:verify` при видимом изменении; `pytest tests_backend`
|
||||
каждой строке; `golden:verify` при метке `ci:golden`; `pytest tests_backend`
|
||||
при правке Python; инварианты модели при правке геометрии; performance —
|
||||
если назван в AC. Полные наборы — предрелизный гейт, а не гейт ревью
|
||||
([§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-гейты)).
|
||||
|
||||
## Повторный раунд
|
||||
|
||||
@@ -122,13 +124,39 @@
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Вперёд двигает только зелёный вердикт; жёлтый и красный возвращают автору
|
||||
([§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
|
||||
- Зелёный вердикт цикла не образует; лимит — 4 цикла, на лёгком и коротком
|
||||
треке 2; бюджет считается по этапу
|
||||
- Зелёный вердикт цикла не образует; лимит — 4 цикла, на `track:show` 2;
|
||||
бюджет считается по этапу
|
||||
([§4](../../PROCESS.md#4-лимит-циклов-ревью-4),
|
||||
[§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Запрещено: Medium-находки, оставленные как TODO в документе ревью;
|
||||
ревью-документы вне репозитория ([§12](../../PROCESS.md#12-запрещено)).
|
||||
|
||||
## Трек show
|
||||
|
||||
- Ревью `show` судит корректность и AC: Medium — дефект поведения, который
|
||||
увидит пользователь, или невыполненный AC; бухгалтерия — нет мутанта или
|
||||
записи в реестре, нечувствительный тест на побочный вызов — Low и цикла не
|
||||
открывает ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер),
|
||||
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Мутанты по диффу на `show` не запрашиваются; их отсутствие не находка
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
- Ветка `show` с чистым слиянием к `dev` до ревью не приводится: материал —
|
||||
ветка как есть, кандидат проверит Validate при слиянии
|
||||
([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)).
|
||||
|
||||
## Пакетное ревью ship
|
||||
|
||||
- Задачи `track:ship` слиты без ревью модели; перед бетой `ship-review.yml`
|
||||
читает их код одной сессией: по строке ТЗ каждой задачи и её коммитам
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
- Вопросы к задаче: делает ли код заявленное и только его, не ломает ли
|
||||
соседнее, не вышла ли правка из ship по смыслу
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой),
|
||||
[§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)).
|
||||
- Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный
|
||||
шаг; High не пускает бету, Medium и Low решает владелец
|
||||
([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)).
|
||||
|
||||
## Независимое ревью линии
|
||||
|
||||
- Перед стабильным релизом `release-review.yml` судит поверхности всей линии
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# CODE-REVIEW #695 r1
|
||||
|
||||
Материал: `origin/dev..HEAD`, ровно один коммит
|
||||
`57ce10721fc0e48cb48b20798d7fb861b4c75ba8` поверх `dev@7d4d75bd`.
|
||||
Диапазон: `PROCESS.md`, `AGENTS.md`, `docs/process/AUTHOR.md`,
|
||||
`docs/process/REVIEWER.md`, `.github/workflows/_process.yml`,
|
||||
`scripts/mutation-registry.mjs`, `scripts/task-packet.mjs`,
|
||||
`test/process-digests.test.mjs`, `test/task-packet.test.mjs`.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Инфраструктурная задача (§1: ни одного файла класса A) — без S1–S6, вход сразу
|
||||
на `S7-code-review`, что подтверждено веткой `issue/695-track-labels` и меткой
|
||||
`process` на issue. Заявленный автором скоуп (комментарий «Взял»): канон
|
||||
треков PROCESS §5/§5.1/§2/§4/§7/§9/§11, конспекты AUTHOR/REVIEWER, AGENTS,
|
||||
`task-packet.mjs`, лимит циклов по `track:*` в `_process.yml`. Явно вне
|
||||
скоупа — поведение конвейера по треку (мутанты, слияние `ship` без ревью
|
||||
модели, ребейз) — это #696, отдельная задача; проверял только то, что заявлено
|
||||
сделанным в #695.
|
||||
|
||||
Проверял по строке кода/текста, а не по заявлению автора: каждый пункт
|
||||
хендоффа сверен с диффом и/или исполнением ниже.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Validate на материале | CI run на `57ce1072` | зелёный (ссылка в задаче ревью), дешёвые гейты (`tsc`, `npm test`, `build`+`bundle-policy`) не перегонял — приняты по этой ссылке (#343) |
|
||||
| Целевой юнит-набор | `node --test test/process-digests.test.mjs test/task-packet.test.mjs test/entry-cost.test.mjs` | 28 pass, 0 fail (прогнано мной точечно поверх зелёного Validate, т.к. это ядро правок) |
|
||||
| `process-gate.mjs` (офлайн) | `node scripts/process-gate.mjs --range origin/dev..HEAD` | «гейт пройден, предупреждений 0» |
|
||||
| `mutation-gate --check` | `node scripts/mutation-gate.mjs --check` | `browser guards: 200/200`; 3 предупреждения — все про `#650` (geometry-corpus/nightly-reuse), не связаны с этим диффом, baseline |
|
||||
| Мутант `process-digest-dead-anchor` | вручную заменил якорь AUTHOR.md `#51-метки-тяжёлых-проверок-и-прежние-метки` → `#51-метки-тяжёлых-проверок`, прогнал `node --test --test-name-pattern="#634 конспект: каждая ссылка" test/process-digests.test.mjs`, вернул файл | **краснеет**: `AssertionError` — тест поймал мёртвый якорь |
|
||||
| Мутант `process-digest-bullet-without-canon-link` | вручную снял `([§5.1](...))` у пункта про `track:show`/`ci:*`, прогнал `--test-name-pattern="#634 конспект: каждый пункт"`, вернул файл | **краснеет**: пункт без ссылки на канон пойман |
|
||||
| Якоря §5/§5.1 в новых ссылках | `headings()`/`markdownLinks()` из `scripts/md-anchors.mjs` (настоящий алгоритм GitHub slug, не схлопывающий дефисы) — покрыто тестом выше | зелёный тест = якоря `5-треки-ship-show-ask--метка-владельца` и `51-метки-тяжёлых-проверок-и-прежние-метки` существуют и совпадают с реальными заголовками |
|
||||
| Ветка/трейлеры | `git log`, `git show -s --format=full HEAD` | один коммит, `Issue: #695`, `User-Visible: no`, ветка `issue/695-track-labels` — соответствует §3 п.10 |
|
||||
|
||||
Не прогонял (и почему): golden/скриншоты, браузерные смоки, `pytest tests_backend`,
|
||||
инварианты модели, `check-docs.mjs`, performance-профили — диапазон не
|
||||
затрагивает `src/**`, `custom_components/**/*.py` ни один файл класса A;
|
||||
`smoke-select.mjs` не запускал по той же причине (нет продуктового диффа,
|
||||
которому смок мог бы соответствовать). `no-new-any.mjs` не применим — TS не
|
||||
менялся.
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе) — «инфраструктура без метки трека» не читается как `track:show` нигде в коде
|
||||
|
||||
**Файл:** `PROCESS.md:607` (§5.1) и отсутствие реализации в
|
||||
`.github/workflows/_process.yml`, `scripts/task-packet.mjs`.
|
||||
|
||||
Новый канон §5.1 утверждает:
|
||||
|
||||
> «...продуктовая задача без трековой метки — как `track:ask`; **инфраструктурная
|
||||
> задача (§1) без трековой метки — как `track:show`**. Новые задачи получают
|
||||
> только `track:*`.»
|
||||
|
||||
Это прямое, машинно-проверяемое правило (наравне с «trivial/small читаются как
|
||||
show», которое реализовано). Скоуп задачи явно включает «лимит циклов по
|
||||
`track:*` в `_process.yml`» и `task-packet.mjs`. Ни один из них правило не
|
||||
реализует:
|
||||
|
||||
- `.github/workflows/_process.yml:87-93` (`guard` job) считает `SMALL=true`
|
||||
только по меткам `small`, `trivial`, `track:show`, `track:ship`. Нет ветки,
|
||||
которая бы распознавала инфраструктурную задачу без трековой метки — сам
|
||||
bash-скрипт вообще не читает признак «инфраструктура» (только `gh issue view
|
||||
--json labels`, без диффа). Инфраструктурная задача без метки трека
|
||||
(обычный случай: конспект автора **нигде** не просит поставить
|
||||
`track:*` инфраструктурной задаче — ни в `docs/process/AUTHOR.md`, ни в
|
||||
«Вход в процесс» этого файла) получает `limit=4`, а не `2`, как обещает §5.1.
|
||||
- `scripts/task-packet.mjs:180-183` (`buildPacket`) для инфраструктурной ветки
|
||||
никогда не вызывает `trackFromLabels()` — track всегда равен
|
||||
`'инфраструктурный'` / `'инфраструктурный (предварительно...)'`, даже когда
|
||||
меток трека нет вовсе. Докстрока `trackFromLabels` (строки 128-134) прямо
|
||||
говорит «задача без трековой метки — как `ask`» без исключения для
|
||||
инфраструктуры, то есть и здесь правило §5.1 не реализовано ни в основной,
|
||||
ни в обходной ветке.
|
||||
|
||||
**Воспроизведение (не гипотеза — существующий зелёный тест доказывает
|
||||
обратное поведение):** `test/task-packet.test.mjs:171-183`
|
||||
(`'#632: statusless or returned infra issue without spec keeps the class A
|
||||
ban'`) собирает пакет с `labels: ['infra', 'S7-code-review']` (нет ни одной
|
||||
`track:*`, `small`, `trivial`) и **утверждает** `packet.track === 'инфраструктурный'`
|
||||
— то есть текущий код по конструкции не даёт `'show'` для этого случая, и тест
|
||||
это фиксирует как ожидаемое поведение уже сейчас, при полностью зелёном прогоне.
|
||||
|
||||
**Почему это не редакционная мелочь.** Практический эффект — не
|
||||
косметический: лимит циклов управляет тем, когда конвейер обязан
|
||||
эскалировать задачу владельцу (`review-4`, §4). Инфраструктурная задача без
|
||||
метки трека (а таких, по конспекту автора, будет большинство — постановка
|
||||
метки трека нигде не предписана для инфраструктурного входа) при 2 подряд
|
||||
жёлтых/красных вердиктах должна была бы уже упереться в лимит и уйти на
|
||||
решение владельца (разделить/отклонить/арбитраж), а по факту реализованного
|
||||
кода получает бюджет 4 — вдвое больше заявленного в собственном канона этой
|
||||
же задачи. Это ровно тот класс расхождения «канон говорит одно, автоматизация
|
||||
делает другое», который сам документ требует не игнорировать, а заводить
|
||||
находкой (шапка PROCESS.md: «Расхождение не игнорируется, а заводится issue с
|
||||
меткой `process`»); поскольку расхождение целиком внутри диффа и заявленного
|
||||
скоупа этой задачи — чинится здесь, не отдельным issue.
|
||||
|
||||
**Чем закрыть:** либо реализовать правило (например, `_process.yml` мог бы
|
||||
трактовать `S7-code-review` без каких-либо `track:*`/`small`/`trivial` меток
|
||||
как `SMALL=true`, когда issue не несёт продуктовых `S1-S6`-признаков — но тогда
|
||||
нужен признак, отличающий это от «продуктовая задача без метки = ask», которого
|
||||
у bash-скрипта сейчас нет вовсе), либо явно понизить формулировку §5.1
|
||||
до «рекомендации», сняв слово «читается как» и обязанность машинного
|
||||
соответствия, как это сделано для temporarily-not-implemented частей `ship`
|
||||
(«пока #696 не влит...», PROCESS.md:562-564) — то есть либо код, либо честная
|
||||
оговорка о недоделке, но не утверждение без покрытия.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Таблица треков §5 и её колонки** (для чего, маршрут, ТЗ, ревью ТЗ,
|
||||
локальный гейт, код-ревью, лимит циклов) — согласована между PROCESS.md,
|
||||
AGENTS.md, `docs/process/AUTHOR.md`, `docs/process/REVIEWER.md`; сверено
|
||||
построчно диффом каждого файла, расхождений в формулировках не нашёл.
|
||||
- **`trackFromLabels()` для явно помеченных задач** (`track:ship` /
|
||||
`track:show` / `track:ask` / легаси `trivial`/`small` / без меток →
|
||||
продуктовая задача) — корректна и покрыта новым тестом
|
||||
`test/task-packet.test.mjs` (`#695: трек по меткам`), прогнан, зелёный.
|
||||
Проверено исполнением, не только чтением.
|
||||
Приоритет `track:ship` → `track:show` → `track:ask` → legacy → default
|
||||
реализован в объявленном порядке (§5: «метка владельца главнее критериев»,
|
||||
явный `track:ask` переопределяет legacy-метки) — сверил построчно с
|
||||
`.github/workflows/_process.yml:87-93`: `if has track:ask; then SMALL=false;
|
||||
TRIVIAL=false; fi` идёт после чтения legacy-меток, порядок совпадает.
|
||||
- **Лимит циклов для явно помеченных `track:show`/`track:ship`/`track:ask`** —
|
||||
`_process.yml` строки 87-99 корректно матчатся: `track:show`/`track:ship`/
|
||||
legacy `small`/`trivial` → `limit=2`, явный `track:ask` возвращает `limit=4`
|
||||
даже поверх legacy-меток. Дешёвая, но реальная проверка: прогнал bash-логику
|
||||
построчно на всех комбинациях меток из `has()`; для случаев, где метка трека
|
||||
явно стоит, поведение соответствует таблице §5 и §4.
|
||||
- **Якоря конспектов (AC2 #634)** — оба новых заголовка (`§5`, `§5.1`) и все
|
||||
ссылки на них из `AUTHOR.md`/`REVIEWER.md` резолвятся настоящим алгоритмом
|
||||
GitHub-слага (`scripts/md-anchors.mjs`, без схлопывания дефисов) — проверено
|
||||
исполнением зелёного `test/process-digests.test.mjs` (5/5) и двумя ручными
|
||||
мутациями, которые «умеют падать» (см. таблицу гейтов).
|
||||
Оба перенацеленных мутанта в `scripts/mutation-registry.mjs`
|
||||
(`process-digest-dead-anchor`,
|
||||
`process-digest-bullet-without-canon-link`) действительно ловят регресс —
|
||||
проверено воспроизведением, не по названию.
|
||||
- **Ключевые формулировки конспекта** («Метка владельца главнее критериев»,
|
||||
«ожидаемое поведение уже зафиксировано») дословно совпадают в PROCESS.md §5 и
|
||||
в `AUTHOR.md`, со ссылкой на верный раздел — проверено тестом
|
||||
`test/process-digests.test.mjs` (правило KEY_RULES), зелёный.
|
||||
- **Трейлеры и провенанс коммита** — один коммит, `Issue: #695`,
|
||||
`User-Visible: no`, ветка `issue/695-track-labels` от `dev@7d4d75bd`;
|
||||
`User-Visible: no` корректен — изменение не задевает ни одного
|
||||
пользовательского поведения (правок в CHANGELOG нет и не требуется).
|
||||
`process-gate.mjs --range origin/dev..HEAD` — 0 предупреждений.
|
||||
- **Отсутствие продуктового кода** — диффом не задет ни один файл класса A
|
||||
(`src/**`, `custom_components/houseplan/**/*.py`, i18n); инфраструктурный
|
||||
маршрут применён верно.
|
||||
- **Обратная совместимость легаси-меток** (`small`/`trivial` → `show`,
|
||||
продукт без метки → `ask`) реализована и в `_process.yml`, и в
|
||||
`task-packet.mjs`, и задокументирована в §5.1 — согласованно во всех трёх
|
||||
местах.
|
||||
- Мусора после диффа не осталось: `grep` по репозиторию не находит мёртвых
|
||||
ссылок на старые заголовки `§5 лёгкий трек`/`§5.1 короткий трек` ни в одном
|
||||
скрипте, тесте или документе вне `docs/reviews/`.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Поведение конвейера, отличное от лимита циклов** (мутанты по диффу на
|
||||
`ship`, слияние `ship` без ревью модели, ребейз-семантика треков) — заявлено
|
||||
автором как вне скоупа (#696), не проверял вовсе.
|
||||
- **Живой прогон `_process.yml` guard job** на реальном issue с `track:show`/
|
||||
`track:ship` — GitHub Actions с реальным `gh issue view` не воспроизводил;
|
||||
проверка bash-логики — чтением и ручной подстановкой значений `has()`, не
|
||||
исполнением job. Записываю явно: **проверено чтением, не исполнением** для
|
||||
всей ветки guard job, кроме той части, что покрыта `process-gate.mjs`
|
||||
(офлайн-подмножество).
|
||||
Тесты, исполняющие именно этот bash-скрипт (`_process.yml`), в репозитории
|
||||
не существуют ни для старой, ни для новой логики — это не новый пробел
|
||||
этой задачи, а существующее свойство инфраструктуры тестирования workflow.
|
||||
- **`docs/reviews/INDEX.md`, golden, скриншоты, смоки, perf, pytest,
|
||||
инварианты модели** — не прогонял: диапазон не касается `src/**`,
|
||||
`custom_components/**/*.py`, визуала или геометрии.
|
||||
- Полный `npm test` (3223 теста) не перегонял целиком — принят по зелёному
|
||||
Validate на этом SHA (#343); перегонял точечно только изменённые файлы
|
||||
(`process-digests`, `task-packet`, `entry-cost`) и мутации по ним.
|
||||
- Не проверял, действительно ли `process-labels/labels.tsv` (вне репозитория,
|
||||
в «папке владельца» по словам автора) создаёт `track:ship`, `track:ask`,
|
||||
`ci:full`, `ci:golden`, `ci:mutants` с корректными цветами/описаниями — это
|
||||
файл вне git-дерева репозитория, недоступен ревью.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка — Medium, в скоупе задачи (реализация лимита циклов по
|
||||
`track:*` в `_process.yml` и track в `task-packet.mjs` — часть заявленного
|
||||
скоупа), не High: явных меток трека это не касается, дефект сужен до случая
|
||||
«инфраструктурная задача без единой метки трека», расхождение делает конвейер
|
||||
мягче объявленного (даёт больше циклов, а не меньше) — не блокирует
|
||||
использование, но противоречит только что написанному собственному канону.
|
||||
Без High это жёлтый вердикт, возврат автору на исправление в этом же issue.
|
||||
|
||||
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 1 → в задаче
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/695-track-labels`, коммит `57ce10721fc0` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `2faf4770f1ba6a32a599552db9c611bfb5cc5a18`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 2faf4770f1ba
|
||||
```
|
||||
- Тело issue: `c74209278f8dd164514eda40d8841d6eefdeffd3b490a90f47b20e3ff2b667e7`
|
||||
- Вердикт конвейера: `yellow` · High 0
|
||||
@@ -0,0 +1,175 @@
|
||||
# CODE-REVIEW #695 r2
|
||||
|
||||
Материал: `origin/dev..HEAD`, ровно `f6c76b7351b1d0b9a61954b0153a44bb098bc7f3`
|
||||
(рабочая копия уже на нём). Диапазон трёх коммитов: `57ce1072` (реализация r1,
|
||||
уже разобрана в CODE-REVIEW-695-r1), `51854aea` (публикация документа r1,
|
||||
служебный коммит конвейера), `f6c76b73` (правка Medium из r1 — материал этого
|
||||
раунда). Дельта этого раунда — ровно `57ce1072..f6c76b73`:
|
||||
`.github/workflows/_process.yml`, `scripts/mutation-registry.mjs`,
|
||||
`scripts/task-packet.mjs`, `test/review-doc-guard.test.mjs`,
|
||||
`test/task-packet.test.mjs`.
|
||||
|
||||
## Скоуп
|
||||
|
||||
Единственная блокирующая находка r1 (Medium, в скоупе) — канон §5.1
|
||||
(«инфраструктурная задача (§1) без трековой метки — как `track:show`») не
|
||||
был реализован ни в `_process.yml` (лимит циклов), ни в `task-packet.mjs`
|
||||
(отображаемый трек). Автор заявляет это закрытым коммитом `f6c76b73`
|
||||
(комментарий «Сделано (r1)»). Разбор по PROCESS.md §2.10 — по дельте: полный
|
||||
пересчёт находки r1, остальное (таблица треков §5, `trackFromLabels()` для
|
||||
явно помеченных задач, якоря конспектов, трейлеры) унаследовано без повтора —
|
||||
дельта их не задевает (ни один из пяти изменённых файлов не входит в
|
||||
доказательную базу этих пунктов r1, кроме `task-packet.mjs`, где тронута
|
||||
только инфраструктурная ветвь `track`, разобранная ниже).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Validate на материале | CI run на `f6c76b73` (ссылка в задаче ревью) | зелёный; дешёвые гейты (`tsc`, `npm test`, `build`+`bundle-policy`) не перегонял — приняты по этой ссылке (#343) |
|
||||
| Целевой юнит-набор | `node --test test/task-packet.test.mjs test/review-doc-guard.test.mjs` | 80/80 и 64/64 pass, 0 fail |
|
||||
| `process-gate.mjs` (офлайн) | `node scripts/process-gate.mjs --range origin/dev..HEAD` | «гейт пройден, предупреждений 0» |
|
||||
| `mutation-gate --check` | `node scripts/mutation-gate.mjs --check` | `guard-infra-keeps-ask-limit` — ok; `packet-infra-track-ignores-show-default` — ok; `browser guards: 200/200`; 3 предупреждения — все про `#650` (baseline, не связаны с диффом — совпадают с r1) |
|
||||
| Мутант `guard-infra-keeps-ask-limit` | вручную заменил `limit=2` на `limit=4 # mutant` в `_process.yml`, прогнал `--test-name-pattern="r1 #695" test/review-doc-guard.test.mjs`, вернул файл (`git diff --stat` после — пусто) | **краснеет**: `AssertionError`, тест ловит мутацию |
|
||||
| Мутант `packet-infra-track-ignores-show-default` | вручную заменил тернарник `hasTrackLabel(labels) ? trackFromLabels(labels) : 'show'` на `trackFromLabels(labels)` в `task-packet.mjs`, прогнал `--test-name-pattern="r1 #695" test/task-packet.test.mjs`, вернул файл | **краснеет**: `AssertionError: expected 'инфраструктурный · show', actual 'инфраструктурный · ask'` |
|
||||
| Исполнение bash-ветки guard (не только чтение) | Извлёк новый блок `.github/workflows/_process.yml:224-242` в отдельный скрипт с mock `gh`/`has`, прогнал 6 сценариев (нет меток+только инфра-файлы; нет меток+файл класса A; явный `track:ask`; явный `track:show`; отказ `gh api`; legacy `small`) | все 6 дали ожидаемый `limit` — см. таблицу «Что проверено» |
|
||||
| Синтаксис bash-шага | `python3 -c "yaml.safe_load(...)"` извлёк `run:` строку `guard`, `bash -n` на неё | синтаксис корректен |
|
||||
| Node-однострочник guard | Прогнал ровно ту же `node --input-type=module -e '...'` строку на трёх наборах путей (только `scripts/`+`test/`; с `src/houseplan-card.ts`; пустой список) | exit 0 / 1 / 1 — соответствует логике «инфраструктура доказана только непустым списком путей без класса A» |
|
||||
| `smoke-select.mjs` | `node scripts/smoke-select.mjs --base 57ce1072 --head f6c76b73` | «Исполняемого frontend-диффа нет (src/**/*.ts не тронут)» — смоки не выбираются, выбирать нечего |
|
||||
| Трейлеры и провенанс коммита | `git show -s --format=full f6c76b73` | `Issue: #695`, `User-Visible: no`, корректно: изменение автоматизации/скриптов, ни одного пользовательского поведения продукта не задето |
|
||||
| Сверка со сформированным пакетом | `node scripts/task-packet.mjs --issue 695` | трек issue #695 (само помечено `track:show` владельцем) печатается как `инфраструктурный · show` — согласуется с явной меткой |
|
||||
|
||||
Не прогонял (и почему): golden/скриншоты, браузерные смоки (подтверждено
|
||||
`smoke-select.mjs` — нечего выбирать), `pytest tests_backend`, инварианты
|
||||
модели, `check-docs.mjs` (диапазон не касается `src/**`), performance-профили —
|
||||
диапазон не затрагивает ни одного файла класса A и не меняет геометрию/рендер.
|
||||
Полный `npm test` (3223 теста) не перегонял целиком — принят по зелёному
|
||||
Validate на этом SHA (#343); перегонял точечно только изменённые тестовые
|
||||
файлы и мутации по ним, плюс собственноручный харнесс исполнения bash-ветки
|
||||
(таблица выше) — этого требует #454 (inline-shell без исполнения не
|
||||
доказывает поведение), выполнено сверх минимума, а не вместо него.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка r1 | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| Medium: §5.1 («инфраструктура без трековой метки — `track:show`») не реализован ни в `_process.yml` (лимит циклов), ни в `task-packet.mjs` (отображаемый трек); зелёный тест `task-packet.test.mjs:171` прямо утверждал старое поведение | `_process.yml`: новая ветка `guard` (строки 224-242) — при отсутствии `track:*`/`small`/`trivial` берёт диффа ветки против `dev` через `compare` API, классифицирует пути `classify()` из `process-gate.mjs`; при непустом списке (< 300, потолок ответа) без единого файла класса A ставит `limit=2`. `task-packet.mjs`: `infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show'` (строка 189), трек инфраструктурной задачи теперь печатается как `` инфраструктурный · ${infraTrack} `` вместо голого `'инфраструктурный'`. Тест `task-packet.test.mjs:171` (`#632`), на который указывала находка, переписан: `assert.equal(packet.track, 'инфраструктурный · show', ...)` — прежнее ожидание `'инфраструктурный'` больше не в коде. | `.github/workflows/_process.yml:224-242`; `scripts/task-packet.mjs:143-144,189-191`; `test/task-packet.test.mjs:110,180,231-241`; `test/review-doc-guard.test.mjs:971-982`; оба новых мутанта (`scripts/mutation-registry.mjs`) реально ловят регресс — проверено воспроизведением (таблица «Как проверялось»), не по названию. Исполнение bash-ветки (6 сценариев харнессом) подтверждает: без метки трека и без класса A → `limit=2`; с любой явной меткой трека → блок не трогает `limit`; при отказе `gh api` или файле класса A → `limit` остаётся консервативным (не понижается ошибочно). |
|
||||
|
||||
Находка полностью закрыта: и механизм (§1: «ни одного файла класса A» —
|
||||
ровно та же формула, что в PROCESS.md §1), и обе точки, которые r1 назвал
|
||||
нереализованными, и тест, который r1 предъявил как доказательство обратного
|
||||
поведения, — всё сходится с текстом §5.1 дословно («инфраструктурная задача
|
||||
(§1) без трековой метки — как `track:show`»).
|
||||
|
||||
## Унаследовано из r1 (без повторной проверки — дельта не задевает)
|
||||
|
||||
- **Таблица треков §5 и её колонки**, согласованность PROCESS.md / AGENTS.md /
|
||||
`docs/process/AUTHOR.md` / `docs/process/REVIEWER.md` — документ
|
||||
`CODE-REVIEW-695-r1.md`, дерево `2faf4770f1ba6a32a599552db9c611bfb5cc5a18`.
|
||||
Ни один из этих четырёх файлов не входит в дельту `57ce1072..f6c76b73`.
|
||||
- **`trackFromLabels()` для явно помеченных задач** (`track:ship` /
|
||||
`track:show` / `track:ask` / legacy `trivial`/`small` / без меток → `ask`),
|
||||
приоритет меток, лимит циклов для явно помеченных `track:show`/`track:ship`/
|
||||
`track:ask` в `_process.yml:87-99` — там же, дерево `2faf4770f1ba6a3...`.
|
||||
Дельта r2 добавляет только новую ветку ПОСЛЕ этой логики (строки 224-242) и
|
||||
не меняет ни один из проверенных r1 путей; сама функция `trackFromLabels`
|
||||
в дельте не тронута (diff `scripts/task-packet.mjs` добавляет только
|
||||
`hasTrackLabel` и меняет строку сборки `track`, не трогая тело
|
||||
`trackFromLabels`).
|
||||
- **Якоря конспектов (AC2 #634), ключевые формулировки, обратная
|
||||
совместимость легаси-меток `small`/`trivial`, трейлеры провенанса,
|
||||
отсутствие продуктового кода в диффе** — там же, дерево
|
||||
`2faf4770f1ba6a3...`; ни один из файлов этой доказательной базы
|
||||
(`PROCESS.md`, `AUTHOR.md`, `REVIEWER.md`, `test/process-digests.test.mjs`)
|
||||
не входит в дельту r2.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Механический признак §1 применён идентично канону**: и в `_process.yml`
|
||||
(список файлов диффа `dev...branch`, ни одного `classify() === 'A'`), и в
|
||||
`PROCESS.md` §1 («ни одного файла класса A») — то же условие, что уже
|
||||
реализовано в `branchIsInfrastructure()` (`task-packet.mjs`, не тронут
|
||||
этим диффом) и в самом `process-gate.mjs`. Никакого нового, отличного от
|
||||
канона определения «инфраструктуры» дифф не вводит.
|
||||
- **Приоритет явной метки владельца сохранён**: guard пропускает новую ветку
|
||||
целиком (`&& ! has track:ship && ! has track:show && ! has track:ask && !
|
||||
has small && ! has trivial`), если стоит любая из пяти меток — идентичный
|
||||
набор в JS (`hasTrackLabel`). Подтверждено исполнением (харнесс, сценарии
|
||||
3, 4, 6) и юнит-тестом `'метка владельца главнее'`.
|
||||
- **Fail-safe при неполном доказательстве**: пустой список файлов, отказ
|
||||
`gh api`, наличие файла класса A или обрезанный (≥300) ответ compare — во
|
||||
всех случаях `limit` остаётся 4 (не понижается ошибочно), задача не
|
||||
наказывается лимитом 2 без доказанной инфраструктуры. Подтверждено
|
||||
исполнением (харнесс, сценарии 2, 5) и отдельным прогоном
|
||||
node-однострочника на пустом списке.
|
||||
- **Мутанты `guard-infra-keeps-ask-limit` и
|
||||
`packet-infra-track-ignores-show-default`** реально ловят регресс —
|
||||
проверено ручным воспроизведением обеих мутаций (не по названию `ok` в
|
||||
`mutation-gate --check`, а прогоном упавшего теста).
|
||||
- **Синтаксис нового bash-блока и YAML-документа** — `bash -n` и
|
||||
`yaml.safe_load` не нашли ошибок; блок стоит внутри уже существующего
|
||||
`if [ -n "$stage" ]`, использует уже установленное `limit` до последующих
|
||||
проверок `EXHAUSTED`/`spent -ge limit` (строки 270-299) — порядок
|
||||
вычисления корректен, «состояния гонки» с более поздним использованием
|
||||
`limit` нет.
|
||||
- **Трейлеры коммита** — `Issue: #695`, `User-Visible: no` (корректно: не
|
||||
задето ни одно пользовательское поведение продукта), ветка
|
||||
`issue/695-track-labels`.
|
||||
- **Согласованность с самим issue #695**: у issue стоит метка `track:show` —
|
||||
собранный пакет (`task-packet.mjs --issue 695`) печатает
|
||||
`инфраструктурный · show`, то есть явная метка отражена корректно и
|
||||
задокументированный пример (issue #695 сам себе) воспроизводится.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- **Живой прогон `_process.yml` guard job** на реальном GitHub Actions
|
||||
раннере с настоящим `gh api compare` — не воспроизводил; логика проверена
|
||||
чтением синтаксиса, ручным bash-харнессом с мок-`gh`/`has` (6 сценариев,
|
||||
см. таблицу) и юнит-тестом на текст блока. **Записываю явно: для самого
|
||||
факта исполнения на реальном GitHub API — проверено чтением и
|
||||
контролируемым харнессом, не живым запуском workflow.** Это не новый
|
||||
пробел этой задачи — тот же пробел зафиксирован в r1 для всей ветки guard
|
||||
job (тестов, исполняющих `_process.yml` напрямую, в репозитории нет ни для
|
||||
старой, ни для новой логики).
|
||||
- **Поведение конвейера по треку** (мутанты на диффе кандидата `ship`,
|
||||
слияние без ревью модели, ребейз-семантика) — вне скоупа #695, это #696,
|
||||
не проверял.
|
||||
- **`process-labels/labels.tsv`** (папка владельца, вне git-дерева) — файл
|
||||
недоступен ревью, как и в r1.
|
||||
- **golden/скриншоты, браузерные смоки** (кроме факта, что `smoke-select.mjs`
|
||||
ничего не выбрал), `pytest tests_backend`, инварианты модели,
|
||||
performance-профили — диапазон не касается визуала, геометрии или Python.
|
||||
- **Ребейз-устойчивость якорей мутантов** `guard-infra-keeps-ask-limit` /
|
||||
`packet-infra-track-ignores-show-default` на дальнейших ребейзах — не
|
||||
тестировал долгосрочную устойчивость find/replace патчей, только их
|
||||
текущую верность материалу.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Единственная находка r1 (Medium, в скоупе) закрыта построчно: оба места,
|
||||
которые она называла нереализованными, реализованы правилом, дословно
|
||||
совпадающим с §5.1; тест, который r1 предъявил как живое доказательство
|
||||
старого поведения, переписан и подтверждает новое; оба новых мутанта реально
|
||||
ловят регресс — проверено воспроизведением, а не по имени `ok`; сама bash-
|
||||
ветка проверена не только чтением, но и контролируемым исполнением на шести
|
||||
сценариях. Новых находок дельта `57ce1072..f6c76b73` не даёт: правка узкая,
|
||||
ограничена ровно заявленным скоупом, не трогает ни один файл класса A, не
|
||||
меняет ни одну ранее проверенную ветвь `trackFromLabels`/таблицы §5.
|
||||
|
||||
Вердикт: зелёный · заход r2 · блокирующих циклов 1/4 · High: 0 · Medium: 0
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/695-track-labels`, коммит `f6c76b7351b1` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `2645a194ffcd661ca9d05eb38db68d9e378faae9`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 2645a194ffcd
|
||||
```
|
||||
- Тело issue: `c74209278f8dd164514eda40d8841d6eefdeffd3b490a90f47b20e3ff2b667e7`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,97 @@
|
||||
# CODE-REVIEW-696-r1
|
||||
|
||||
Материал раунда: `e1ae8f4ac70f9342b9faf221c4cba84141e213b3` (единственный коммит поверх `dev@9e4bfb43`, ветка `issue/696-pipeline-by-track`). Этап: code. Заход r1, блокирующих циклов израсходовано 0/4. Трек — `track:ask` (владелец повысил его сам в комментарии «Взял», причина — задача меняет путь слияния в `dev` и добавляет гейт беты).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Продолжение #695: конвейер ревью перестаёт стоить одинаково для `ask`, `show` и `ship` (PROCESS.md §5, §10.4 — новая таблица цены захода; §11.7 — новый раздел «Пакетное ревью `ship` перед бетой»). Изменение целиком инфраструктурное (файлов класса A нет), Core user jobs `docs/SCOPE.md` не касается — это внутренний процесс, не продуктовая поверхность.
|
||||
|
||||
Файлы: `.github/workflows/_process.yml`, `.github/workflows/publish-prerelease.yml`, новый `.github/workflows/ship-review.yml`; `scripts/process-track.mjs` (новый), `scripts/ship-review.mjs` (новый), правки `scripts/ci-proof.mjs`, `scripts/merge-candidate.mjs`, `scripts/validate-gate.mjs`, `scripts/release-prerelease.mjs`, `scripts/reviews-archive.mjs`, `scripts/reviews-index.mjs`, `scripts/task-packet.mjs`, `scripts/mutation-registry.mjs` (+9 новых мутантов, 3 перенацелены); тесты `test/process-track.test.mjs` (новый), `test/ship-review.test.mjs` (новый), правки `test/merge-candidate.test.mjs`, `test/validate-gate.test.mjs`, `test/review-doc-guard.test.mjs`; документы `PROCESS.md`, `docs/process/REVIEWER.md`, `docs/process/AUTHOR.md`, `AGENTS.md`.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
| Гейт | Прогнан | Результат |
|
||||
|---|---|---|
|
||||
| `npx tsc --noEmit`, `npm test` (весь набор), `npm run build` + сверка бандла | Нет — Validate на `e1ae8f4a` зелёный (ссылка в промпте), гейты подтверждены (#343) | — |
|
||||
| `node scripts/check-docs.mjs` | Нет | diff не трогает `src/**` — гейт неприменим |
|
||||
| Целевой прогон новых/изменённых тестовых файлов | Да, напрямую `node --test` | `test/process-track.test.mjs` 28/28, `test/ship-review.test.mjs` 18/18 (одной командой, см. ниже), `test/validate-gate.test.mjs`+`test/merge-candidate.test.mjs`+`test/review-doc-guard.test.mjs` 106/106, `test/reviews-index.test.mjs`+`test/reviews-archive.test.mjs` 21/21, `test/task-packet.test.mjs` 16/16, `test/process-digests.test.mjs` 5/5 — все зелёные |
|
||||
| «Тест умеет падать» для defensive AC | Да, вручную для 2 из 9+3 мутантов реестра, остальные — чтением | см. таблицу ниже |
|
||||
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | Да | «Исполняемого frontend-диффа нет» — смоки не выбираются, `src/**` не тронут |
|
||||
| `golden:verify` | Нет | diff не меняет рендер |
|
||||
| `pytest tests_backend` | Нет | diff не трогает `custom_components/**/*.py` |
|
||||
| Инварианты модели | Нет | diff не трогает геометрию |
|
||||
| Performance-профили | Нет | не названы в задаче |
|
||||
| `actionlint` / `bash -n` на новых и изменённых workflow-файлах | Нет (бинаря нет в среде) | автор заявил «чисто»; синтаксис `run:`-блоков проверен чтением, YAML-структура — тестами `test/process-track.test.mjs`/`test/ship-review.test.mjs`/`test/review-doc-guard.test.mjs`, которые матчат конкретные строки конвейера |
|
||||
| `node --check` на новых/изменённых `.mjs` | Да | `process-track.mjs`, `ship-review.mjs`, `mutation-registry.mjs` — без синтаксических ошибок |
|
||||
|
||||
Рабочая копия после проверок чистая (`git status --porcelain` пуст), `HEAD` не сдвигался.
|
||||
|
||||
### Проверка «тест умеет падать» (defensive AC)
|
||||
|
||||
Полный реестр `mutation-registry.mjs` не гонял (28 минут, ночной цикл его покрывает; трек `ask`, но полный прогон мутантов — не гейт ревью, а Validate-дispatch, который уже зелёный). По двум мутантам проверил вручную (применил патч → тест упал → откатил):
|
||||
|
||||
| Мутант | Что убивает | Проверено |
|
||||
|---|---|---|
|
||||
| `ship-limit-off-by-one` (`>` → `>=` на границе 30 строк) | `рамки ship: строки src/** считаются вместе, граница включительна (#696)` | Исполнением: применил, `node --test` упал на `assert.deepEqual(shipLimitViolations({numstat:[at(20,10)]}), [])` (30 строк дало ложное нарушение) |
|
||||
| `ship-review-accepts-high` (отключение проверки `block.high > 0`) | `#696 гейт: машинный блок покрывает все задачи и не несёт High` | Исполнением: применил, `node --test` упал (`0 !== 1`) |
|
||||
| остальные 7 новых + 3 перенацеленных | см. ниже | Чтением, не исполнением |
|
||||
|
||||
Остальные разобраны чтением кода и тестов:
|
||||
- `track-show-pays-for-mutants` — `mutants = true` вместо `track === 'ask' || ci:mutants` убивается тестом «мутанты по диффу — только ask и метка ci:mutants», который явно проверяет `mutants: false` для `track:show`.
|
||||
- `ship-limits-miss-new-src-file` — `false && added.length` убирает проверку новых файлов в `src/**`; тест «рамки ship: новые файлы…» проверяет ровно этот случай через `v.some(s => s.startsWith('новые файлы')...)`.
|
||||
- `pipeline-ship-ignores-limits` — замена `if printf ... grep -qx 'ship=true'; then` на `if true; then` в YAML; убивается текстовым `assert.match` в «конвейер: трек снимается до ребейза…», который ищет буквальную строку.
|
||||
- `light-review-waits-running-push` (`proofCandidate` перестаёт требовать `status==='completed'` для push) — убивается тестом «#696: without mutants a red push run returns the task, a push still running is not waited for»: с мутацией идущий push немедленно принимается за доказательство и гейт возвращает `pending` без диспатча, тест ждёт `dispatched === ['issue/1:light']`.
|
||||
- `light-merge-dispatches-second-run` — мутация заставляет всегда диспатчить второй прогон; тест «трек show/ship — слияние ждёт push-прогон кандидата, без второго dispatch» проверяет `!ops.calls.some(c => c[0] === 'dispatch')`.
|
||||
- `ship-review-ignores-merge-marker` — тест «ship-задача — по маркеру конвейера или метке track:ship» проверяет включение по маркеру при пустых метках.
|
||||
- `ship-review-accepts-partial-coverage` — тест «машинный блок покрывает…» проверяет `partial.length === 1` для непокрытой задачи.
|
||||
|
||||
Пустого третьего столбца («чем краснеет») в реестре нет ни у одного из 9+3 пунктов — соответствует §2.7.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High/Medium) находок нет.
|
||||
|
||||
Разобрал один потенциальный источник расхождения и снял его как безопасный (без записи как Low — поведенческого расхождения нет):
|
||||
|
||||
- `scripts/process-track.mjs`'s `resolveTrack` вычисляет признак «инфраструктура» как `files.every(f => classify(f) !== 'A')`, **не** исключая `docs/reviews/**`, в отличие от параллельной `branchIsInfrastructure` в `task-packet.mjs`, которая явно фильтрует `docs/reviews/**` (комментарий про #632: ветка S6-задачи до первого кодового коммита не должна выглядеть инфраструктурной). Проверил, манифестируется ли это различие: шаг «Трек задачи и рамки ship» в `_process.yml` вызывается только на `STAGE=spec` и `STAGE=code` (сам конвейер срабатывает только на метках `S4-spec-review`/`S7-code-review`); шаг ребейза и вся ship/mutants-логика дополнительно ограничены `needs.guard.outputs.stage == 'code'`. На `STAGE=code` ветка по определению уже несёт хотя бы один класса-A коммит (иначе `S7-code-review` не была бы проставлена), поэтому `docs/reviews/**` в диффе не меняет исход `every(...) !== 'A'` — класс-A файл уже есть. На `STAGE=spec` вывод трека не влияет ни на что: `npm ci`/Chromium решает отдельная проверка `STAGE=='spec'`, а вся ship/mutants-ветка кода закрыта условием `STAGE=='code'`. Разошедшийся, но недостижимый код — не находка; дублирование логики (два места считают «инфраструктуру» по-разному) можно было бы вынести в одну функцию, но это стилистическое желание, а не дефект.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Цена по треку (§10.4).** `process-track.mjs`: `resolveTrack` — явная метка (`track:*`, включая старые `trivial`/`small` → `show`) главнее эвристики по диффу; `mutants` — только `ask` или `ci:mutants`; `shipLimitViolations` — граница 30 строк `src/**` включительна, ловит бинарники, новые файлы, i18n, `types.ts`/`config-*.ts`, Python. Все ветки покрыты тестами и (по образцу) — исполнением.
|
||||
- **Лёгкий Validate (`validate-gate.mjs`).** `proofCandidate` для `mutants:false` принимает и завершённый push-прогон на материале, и dispatch; ждать можно только dispatch (push, который ещё идёт, не разбудит `process-resume.yml`) — комментарий и код согласованы, подтверждено тестами `#696` в `validate-gate.test.mjs` (все проходят).
|
||||
- **Лёгкое слияние (`merge-candidate.mjs`).** На `show`/`ship` слияние ждёт push-прогон уже опубликованного кандидата и диспатчит только если его нет за 3 минуты; на `ask` поведение не изменилось (отдельный тест это явно проверяет). Приоритет: fast-forward (dev не двигался) — старая ветка кода, без Validate вообще; light-merge — только когда `devMoved` истинен.
|
||||
- **Пропуск ребейза до ревью.** Условие `git merge-tree --write-tree origin/dev HEAD` для `show`/`ship`; при конфликте — обычный ребейз с помощником, как на `ask`. Ограничено `STAGE=='code'`.
|
||||
- **`ship` в рамках.** Downgrade `track:ship → track:show` при выходе за рамки происходит в том же заходе, до слияния (не после); `model_review` job корректно пропускается только когда `ship==true` (условие job'а `... && needs.prepare.outputs.ship != 'true'`); `integrate` job не блокируется пропуском модели (`if: always() && needs.guard.outputs.stage != ''`, и `ready`-шаг явно допускает `SHIP=true` без ожидания `MODEL_RESULT`). Маркер `hp:ship-merge` пишется только в этом пути и не называет себя вердиктом (проверил текстом комментария и тестом, который явно это утверждает).
|
||||
- **Приоритет reuse над ship (§499).** В `integrate`: `if SHIP && !REUSE ... elif REUSE ...` — если оба истинны, применяется повторно применимый зелёный вердикт, а не ship-маркер, как и требует PROCESS.md.
|
||||
- **Пакетное ревью ship (`ship-review.mjs`, `ship-review.yml`).** `isShipIssue` — по маркеру ИЛИ по текущей метке `track:ship` (тест подтверждает оба пути и отрицательный случай); `shipIssuesInRange` строит список по трейлерам `Issue: #NN` первого-родителя истории от прошлого тега (та же функция, что `RELEASE-MEMBERSHIP.json`); `shipReviewProblems` отказывает без документа, без покрытия всех ship-задач или при `High > 0`, и не отказывает без ship-задач в диапазоне (документ не нужен). Модель в `ship-review.yml` без прав на запись (`contents: read`, явный `github_token`, без обмена OIDC — совпадает с #556), документ публикует детерминированный `publish` job.
|
||||
- **Гейт беты в обоих путях.** `publish-prerelease.yml` (`gate` job) и `scripts/release-prerelease.mjs` (`main`, до `if (checkOnly) return`) оба зовут `ship-review.mjs check` — подтверждено тестом и чтением; `readShipDoc` ищет документ сначала в кандидате, затем в `origin/dev` — корректно для случая «ship-review.yml закоммитил документ в dev до создания тега».
|
||||
- **Архив и индекс.** `SHIP-REVIEW-<tag>` распознаётся `parseDocName`, попадает в общую таблицу «линия/бета» индекса, сортировку по тегу с бетами ниже релиза; архивация уводит в каталог стабильной линии (`beta.N` суффикс отрезается для сопоставления с `tags`).
|
||||
- **Трейлеры и changelog.** Коммит несёт `Issue: #696`, `User-Visible: no` — changelog не требуется и не тронут (подтверждено `git diff --stat`, `docs/CHANGELOG*.md` в диффе нет).
|
||||
- **Документация.** `PROCESS.md` §5, §5.1, §10.4 (новая таблица), §11.7 — согласованы с `REVIEWER.md` («Трек show», «Пакетное ревью ship») и `AUTHOR.md`/`AGENTS.md`; markdown-якоря (`#117-пакетное-ревью-ship-перед-бетой` и т. д.) соответствуют заголовкам; `test/process-digests.test.mjs` (сверяет конспект с каноном) зелёный.
|
||||
- **Раскрытое отклонение от текста issue.** Автор оставил документ ревью `show` файлом в `docs/reviews/`, а не комментарием, как предлагалось в issue — с явным обоснованием (зависимость #499/#413/счёт раундов) в комментарии «Взял». Решение по существу, а не техническим вопросом владельцу — принимаю его как обоснованную инженерную поправку к предложению, не как дефект.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный `npm test`/`tsc`/`build`/`mutation-gate` целиком заново — не гонял, полагаясь на зелёный Validate на этом SHA (#343) и точечные перезапуски затронутых файлов (все зелёные).
|
||||
- `actionlint`/`bash -n` — инструмента нет в среде ревью; синтаксис новых `run:`-блоков проверен только чтением и совпадением с тестами, которые парсят эти же строки текстом.
|
||||
- Реальный прогон `ship-review.yml` (workflow_dispatch) и `publish-prerelease.yml` end-to-end на GitHub Actions — оценивал по коду и юнит-тестам, не по фактическому прогону workflow (у ревью нет доступа к запуску Actions).
|
||||
- Мутанты реестра — 2 из 12 (9 новых + 3 перенацеленных) убил исполнением, остальные 10 разобраны чтением кода и утверждений тестов (см. таблицу выше), «проверено чтением, не исполнением».
|
||||
- Браузерные смоки, golden, pytest, инварианты модели, performance — не запускал: `smoke-select` и осмотр диффа показывают, что `src/**`/геометрия/Python/рендер не затронуты, эти гейты неприменимы к этому диффу.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. AC issue (5 пунктов предложения) реализованы и подтверждены тестами и точечным исполнением; единственное отклонение от текста issue раскрыто и обосновано автором. High/Medium находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/696-pipeline-by-track`, коммит `e1ae8f4ac70f` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `9be4c7079bacdba3cb21a4c0d2caa84fbf7299fc`
|
||||
```
|
||||
git log --all --format='%H %T' | grep 9be4c7079bac
|
||||
```
|
||||
- Тело issue: `398bd7cddfc58767cac5aa98d94385c4fb3a38babe04ac379a9517e9bf530ef0`
|
||||
- Вердикт конвейера: `green` · High 0
|
||||
@@ -0,0 +1,210 @@
|
||||
# CODE-REVIEW-697-r1
|
||||
|
||||
Issue: [#697](https://github.com/Matysh/houseplan-card/issues/697) · этап: code · заход: r1 ·
|
||||
блокирующих циклов израсходовано 0 из 4 · трек: `ask` (поднят автором с причиной,
|
||||
задача меняет два общих механизма конвейера) · маршрут: инфраструктурная задача,
|
||||
`S2`/`S3` пропущены (§1) · материал: **`2a62ad5b95e02b19f339ef71e67dbf07265adcce`**
|
||||
(единственный коммит ветки `issue/697-derived-on-dev` поверх `dev@c716bb0f`).
|
||||
|
||||
## Скоуп
|
||||
|
||||
Задача переносит два производных артефакта — отпечаток скриншотов документации
|
||||
(`docs/images/screenshots.json` + кадры) и эталоны golden
|
||||
(`demo/golden/baselines/**`) — с веток задач на `dev`, принимая их один раз на
|
||||
бету одним коммитом бота (`beta-derived.yml`), вместо того чтобы каждая задача с
|
||||
`src/**`-диффом их коммитила и разрешала конфликты. Второе следствие: трейлер
|
||||
`Release:` на ветке задачи больше не включает тяжёлый набор Validate (смоки,
|
||||
golden, perf) — его теперь включают только метки `ci:full`/`ci:golden`, и гейт
|
||||
материала ревью (`validate-gate.mjs`) сам диспатчит `full=true`, не принимая
|
||||
лёгкий push-прогон как доказательство.
|
||||
|
||||
Файлов класса A (`src/**`, backend Python, манифесты, i18n) в диффе нет —
|
||||
подтверждено `git diff --stat origin/dev...HEAD` (15 файлов: `.github/workflows/**`,
|
||||
`scripts/**`, `test/**`, `PROCESS.md`, `docs/process/**`, `CONTRIBUTING.md`).
|
||||
Это ровно определение инфраструктурной задачи из §1, поэтому пропуск `S2-analysis`
|
||||
и `S3-spec` корректен — предложение и решение владельца зафиксированы в теле
|
||||
issue и его первом комментарии, а не в отдельном ТЗ.
|
||||
|
||||
Какую строку `docs/SCOPE.md` это обслуживает: задача не продуктовая, это
|
||||
`PROCESS.md`/пайплайн, явно разрешённый класс правок (§1, класс B) — сам
|
||||
`docs/SCOPE.md` о ней не говорит, и это ожидаемо.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Проверялась ровно дельта: единственный коммит целиком, других раундов не было
|
||||
(r1), поэтому раздел «Закрытие раунда r0» не пишется.
|
||||
|
||||
Прочитано построчно:
|
||||
- `PROCESS.md` — весь дифф (§3 п.13, §5.1, §8, §11.4);
|
||||
- `docs/process/AUTHOR.md`, `docs/process/REVIEWER.md`, `CONTRIBUTING.md` — весь дифф;
|
||||
- `.github/workflows/beta-derived.yml` — целиком (новый файл, 215 строк);
|
||||
- `.github/workflows/_process.yml`, `.github/workflows/validate.yml` — весь дифф;
|
||||
- `scripts/classify-changes.mjs`, `scripts/process-track.mjs`,
|
||||
`scripts/validate-gate.mjs`, `scripts/mutation-registry.mjs` — весь дифф плюс
|
||||
окружающий контекст (`ci-proof.mjs::evaluateCiProof`, `requiredCheckIds`,
|
||||
`merge-candidate.mjs`) — не изменены этим диффом, но нужны, чтобы понять,
|
||||
дотягивается ли `full=true` до кандидата слияния;
|
||||
- `test/beta-derived.test.mjs`, `test/classify-changes.test.mjs`,
|
||||
`test/process-track.test.mjs`, `test/validate-gate.test.mjs` — весь дифф.
|
||||
|
||||
Независимо от заявлений автора в issue я перепроверил Validate-прогон
|
||||
[36480865713](https://github.com/Matysh/houseplan-card/actions/runs/36480865713)
|
||||
через `gh run view`/`gh api`:
|
||||
- прогон — `workflow_dispatch` на `issue/697-derived-on-dev`@`2a62ad5b`, `conclusion: success`;
|
||||
- лог job «Классификация изменённых файлов» печатает `heavy=false`,
|
||||
`mutants_requested=true` — то есть на материале был запрошен мутационный, но
|
||||
не полный набор (метки `ci:full`/`ci:golden` на issue нет, диффа по `src/**`
|
||||
тоже нет — ожидаемо);
|
||||
- все шесть шардов `Мутанты по диффу (N/6)` — `success`; смоки/golden/perf —
|
||||
`skipped` (согласовано с `heavy=false`);
|
||||
- логи шардов подтверждают убийство ИМЕННО четырёх новых мутантов #697:
|
||||
`bot-golden-commit-without-provenance`, `ci-golden-label-does-not-order-full-set`,
|
||||
`review-gate-accepts-light-proof-for-full`, `task-branch-release-trailer-heavy-again`
|
||||
— каждый напечатал `ok <id>: заявленный тест покраснел на мутанте`.
|
||||
|
||||
Это и есть таблица «AC · чем доказан · чем краснеет» для защитных AC этой
|
||||
задачи:
|
||||
|
||||
| Защитный AC | Чем доказан | Чем краснеет (мутант · результат) |
|
||||
|---|---|---|
|
||||
| Ветка задачи с `Release:` не включает тяжёлый набор повторно | `test/classify-changes.test.mjs` (`#697: на ветке задачи…`), исполнено | `task-branch-release-trailer-heavy-again` — `success` на прогоне 36480865713 |
|
||||
| `ci:golden` без `ci:full` тоже заказывает полный набор на материале | `test/process-track.test.mjs` (`#697: полный набор…`), исполнено | `ci-golden-label-does-not-order-full-set` — `success` |
|
||||
| Лёгкий push-прогон не засчитывается доказательством при `full=true` | `test/validate-gate.test.mjs` (`#697: ci:full/ci:golden…`), исполнено; плюс `evaluateCiProof`: `policy?.full && !proof.request?.full → stale` (`scripts/ci-proof.mjs:376`, не менялся этим диффом, переиспользован) | `review-gate-accepts-light-proof-for-full` — `success` |
|
||||
| Коммит бота с изменёнными эталонами обязан нести `Release:`/`Baseline-Reviewed:`, иначе провенанс отклонит | `test/beta-derived.test.mjs` (`#697: сообщение коммита…`), исполнено через `validateCommitMessage` | `bot-golden-commit-without-provenance` — `success` |
|
||||
|
||||
Пустых третьих столбцов нет — все четыре мутанта реально прогнаны на материале,
|
||||
не только заявлены.
|
||||
|
||||
Дополнительно прогнано вручную в этом ревью (дёшево, воспроизводимо):
|
||||
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` →
|
||||
«Исполняемого frontend-диффа нет… Browser-smoke этим диффом не выбираются».
|
||||
Решение: смоки не нужны — диффа по `src/**` нет, выбирать нечего, а не
|
||||
«пропустить проверку».
|
||||
- `actionlint` (бинарь `v1.7.7`, установлен вручную в `/tmp`, в окружении не был)
|
||||
на все три изменённых workflow: единственные находки — 8 preexisting
|
||||
shellcheck-предупреждений (стиль/info) в `_process.yml`/`validate.yml` на
|
||||
строках, которые этот дифф не трогает (проверено построчным сравнением с
|
||||
`origin/dev`); `beta-derived.yml` — чисто.
|
||||
- `python3 -c 'yaml.safe_load(...)'` на все три workflow — валидный YAML.
|
||||
- Прочитаны (не исполнены) `scripts/ci-proof.mjs::evaluateCiProof`,
|
||||
`requiredCheckIds`, `scripts/merge-candidate.mjs` — чтобы ответить, доходит
|
||||
ли `ci:full`/`ci:golden` до кандидата слияния (см. находки — нет, и это не
|
||||
дефект, см. ниже).
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **Дешёвые гейты подтверждены зелёным Validate на точном SHA материала**
|
||||
(ссылка выше, `conclusion: success`) — `typecheck`, `npm test`, `npm run build`
|
||||
+ `bundle-policy --verify` перегонять не нужно.
|
||||
- **Мутанты по диффу** — обязательны на треке `ask`, прогнаны на материале
|
||||
(dispatch, не push), все шесть шардов зелёные, четыре новых мутанта явно
|
||||
проверены построчно в логах (см. таблицу выше).
|
||||
- **Трейлеры коммита**: `Issue: #697`, `User-Visible: no` — верно: изменение не
|
||||
видимо пользователю карточки, changelog не нужен. Один коммит, ветка
|
||||
`issue/697-derived-on-dev`, без PR — соответствует правилу репозитория.
|
||||
- **`isTaskBranch`/`heavyGatesRequested`**: порядок проверок в
|
||||
`scripts/classify-changes.mjs` корректен — `pull_request`/`workflow_dispatch`/
|
||||
`schedule` решают исход до проверки ветки, `isTaskBranch` перехватывает только
|
||||
обычный `push` на `issue/*`, и только там глушит `Release:`. На `dev` и вне
|
||||
ветки задачи (`refName` не передан) поведение не изменилось — тест это
|
||||
явно закрывает (`'без ветки — прежнее правило'`). Оба вызова
|
||||
`classify-changes.mjs` в `validate.yml` (`--heavy` и `--screenshots-mode`)
|
||||
получают `REF_NAME` — проверено регэкспом в тесте и вручную (`grep -c`).
|
||||
- **`validate-gate.mjs`/`ci-proof.mjs` совместность**: `policy.full` — не новое
|
||||
поле «для галочки»: `evaluateCiProof` действительно отклоняет прокси с
|
||||
`stale`, если `proof.request.full` не совпадает с ожиданием политики
|
||||
(`scripts/ci-proof.mjs:376`), и `requiredCheckIds` при `request.full`
|
||||
требует зелёных `smoke`/`golden`/`performance_smoke` (строка 249, не
|
||||
изменена этим диффом, но именно на неё опирается новый `--full`). Это
|
||||
честное переиспользование существующего механизма (было введено под
|
||||
`release`-политику), а не декоративный флаг.
|
||||
- **`merge-candidate.mjs` не тронут и не обязан быть тронут этим диффом.**
|
||||
Я проверил, не открывает ли это дыру для `ci:golden`-задач: слияние
|
||||
сверяет `patch-id` дифф-ветки после ребейза с диффом, проверенным на ревью
|
||||
(`scripts/merge-candidate.mjs:274, 282`), и при расхождении **отказывает**
|
||||
и возвращает issue в `S7-code-review`, а не сливает молча. Значит любое
|
||||
изменение golden-файлов, прошедшее полный Validate на материале ревью,
|
||||
либо доедет до `dev` байт-в-байт (patch-id равен), либо слияние
|
||||
переоткроет ревью — полный набор на кандидате слияния отдельно не нужен.
|
||||
Это не новая находка, а подтверждение того, что задача корректно не
|
||||
расширяла свой скоуп на файл, где менять было нечего.
|
||||
- **Документация синхронна с кодом**: `PROCESS.md` §3 п.13/§5.1/§8/§11.4,
|
||||
`docs/process/AUTHOR.md`, `docs/process/REVIEWER.md`, `CONTRIBUTING.md`
|
||||
правлены в одном коммите с кодом и друг другу не противоречат;
|
||||
`publish-prerelease.yml` (не в диффе) по-прежнему держит
|
||||
`check-docs --screenshots=strict` на кандидате — заявленный в PROCESS.md
|
||||
«предохранитель» существует и не сломан этим диффом.
|
||||
- **`beta-derived.yml`**: без прав `contents: write` у job, пишет в `dev`
|
||||
единственным `git push` без `--force` через `HP_PROCESS_TOKEN` (существующий
|
||||
секрет, уже используемый в остальном конвейере); коммит golden несёт
|
||||
`Release:`/`Baseline-Reviewed:` только когда golden реально изменился;
|
||||
подпись коммита (`docs: accept derived artifacts on dev for …`) намеренно не
|
||||
похожа на кандидата — проверено `isCandidateSubject(...) === false`
|
||||
(`bundle-policy.mjs`, не менялся). Приёмка golden требует завершённого
|
||||
`Validate` именно на `dev` (`path`/`branch`/`status` проверяются перед
|
||||
`gh run download`) — чужой прогон или прогон на ветке задачи доказательством
|
||||
не станет.
|
||||
- **Одно число — один источник**: изменение не трогает ни одну пользователю
|
||||
видимую величину (нет диффа в `src/**`, нет правок пользовательских текстов),
|
||||
вопрос неприменим.
|
||||
|
||||
## Находки
|
||||
|
||||
Блокирующих (High) находок нет. Находок Medium в скоупе или вне скоупа нет.
|
||||
|
||||
Low, снятые без правки (запись, не находка на вердикт):
|
||||
- `beta-derived.yml` ни разу не запускался «вживую» (пишет в `dev`, а это
|
||||
публичная запись, согласованная только на бету) — открыто заявлено автором
|
||||
в разделе «НЕ сделано» issue-комментария. Первый реальный прогон пойдёт под
|
||||
контролем релиз-менеджера перед ближайшей бетой с ручной проверкой диффа
|
||||
`docs/images`/`demo/golden/baselines` до принятия — то есть у механизма есть
|
||||
человеческий контроль на первом реальном использовании, а не слепое доверие
|
||||
синтетическому тесту текста workflow. Отмечаю как остаточный риск first-run,
|
||||
не как дефект кода.
|
||||
- Опциональный пункт «привязать приёмку к хешу входов рендера, а не к
|
||||
commit/tree» из тела issue не реализован — но он явно помечен
|
||||
«Опционально» и решения владельца по нему не запрашивалось (только два
|
||||
вопроса из «Нужно решение владельца» с ответами «да»/«бот»). Не пропуск AC.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Не исполнял `beta-derived.yml` (ни живьём, ни через `act`) — GitHub Actions
|
||||
workflow с реальным `gh api`/push в `dev`; проверка построчная, чтением, не
|
||||
исполнением (`step()`-парсинг совпадает с тем, что делают
|
||||
`test/beta-derived.test.mjs`).
|
||||
- Не прогонял `npm run golden:verify`, `python -m pytest tests_backend`,
|
||||
`npm run invariants` — не применимы: диффа по визуалу/Python/геометрии нет
|
||||
(подтверждено `smoke-select`, `git diff --stat`), и на issue нет меток
|
||||
`ci:golden`/правок `custom_components/**/*.py`.
|
||||
- Не повторял `npx tsc --noEmit`/`npm test`/`npm run build` локально — покрыты
|
||||
зелёным Validate 36480865713 на точном SHA материала, что честно
|
||||
засчитывается по правилам этого прогона (#343).
|
||||
- Не проверял `scripts/golden-accept.mjs`/`scripts/docs-accept.mjs`/
|
||||
`scripts/validate-commit-provenance.mjs` изнутри — они не изменены этим
|
||||
диффом (`git diff --stat` подтверждает), это переиспользуемые механизмы
|
||||
более ранних задач (#246, #573, #657); их корректность — не предмет этого
|
||||
ревью.
|
||||
- Не проверял `ship-review.yml`/`release-review.yml` на предмет использования
|
||||
`HP_PROCESS_TOKEN` или новых меток — они этим диффом не тронуты.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Зелёный. Задача сузила проверяемый периметр правильно (никакого файла класса A,
|
||||
инфраструктурный маршрут обоснован), тесты — исполняемые, а не только текстовые
|
||||
грепы поверх прозы, и все четыре заявленных мутанта я перепроверил по логам
|
||||
реального CI-прогона на точном SHA материала, а не поверил заявлению автора.
|
||||
Документация синхронна с кодом в одном коммите. Блокирующих находок нет.
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/697-derived-on-dev`, коммит `2a62ad5b95e0` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `b3ce3332568fabce6d9204ffc8f0fa27cbd31e35`
|
||||
```
|
||||
git log --all --format='%H %T' | grep b3ce3332568f
|
||||
```
|
||||
- Тело issue: `8a1b08521497d715ba4b7870ef91556ec417feec89151bec0daf9733401188c2`
|
||||
- Вердикт конвейера: `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
|
||||
+15
-1
@@ -1,9 +1,23 @@
|
||||
# Индекс ревью
|
||||
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 173, issue: 80. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 187, issue: 88. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
|
||||
|
||||
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|
||||
|---|---|---|---|---:|---:|---|---|
|
||||
| #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 | — | — |
|
||||
| #691 | [SPEC-REVIEW-691-r1.md](SPEC-REVIEW-691-r1.md) | spec · r1 | 🟡 жёлтый | 0 | 1 | направление edge-swipe не квантифицировано и не отмечено как допущение | `src/houseplan-card.ts` |
|
||||
| #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` |
|
||||
|
||||
@@ -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,16 @@ export const CI_PROOF_STATES = Object.freeze([
|
||||
// требует (#601): к кандидату беты каждая задача прогнана ими на ревью и на
|
||||
// слитом кандидате, а `Release:` мутантов больше не запрашивает — политика
|
||||
// с `mutants: true` объявляла бы каждый кандидат беты `stale`.
|
||||
//
|
||||
// #696: треки `show` и `ship` (PROCESS §5) мутантов по диффу до слияния не
|
||||
// запрашивают — их доказательство лёгкое, `reviewLight`/`mergeLight`. Мутанты
|
||||
// у них остаются в ночном полном реестре; `track:ask` и метка `ci:mutants`
|
||||
// идут по прежним `review`/`merge`.
|
||||
export const CI_PROOF_POLICIES = Object.freeze({
|
||||
review: Object.freeze({ name: 'review', full: false, mutants: true }),
|
||||
merge: Object.freeze({ name: 'merge', full: false, mutants: true }),
|
||||
reviewLight: Object.freeze({ name: 'review-light', full: false, mutants: false }),
|
||||
mergeLight: Object.freeze({ name: 'merge-light', full: false, mutants: false }),
|
||||
release: Object.freeze({ name: 'release', full: true, mutants: false }),
|
||||
});
|
||||
|
||||
|
||||
@@ -88,13 +88,23 @@ export function classifyAll() {
|
||||
* - `workflow_dispatch` с `full=true` — ночной прогон (nightly.yml) и ручной;
|
||||
* - pull_request — там Validate единственный сигнал.
|
||||
*/
|
||||
export function heavyGatesRequested({ eventName, headMessage, fullInput } = {}) {
|
||||
export function heavyGatesRequested({ eventName, headMessage, fullInput, refName } = {}) {
|
||||
if (eventName === 'pull_request') return true;
|
||||
if (eventName === 'workflow_dispatch') return String(fullInput) === 'true';
|
||||
if (eventName === 'schedule') return true;
|
||||
// #697: на ветке задачи трейлер `Release:` тяжёлый набор больше не включает.
|
||||
// Его там носит только приёмка эталонов, а golden на ветке задачи — по метке
|
||||
// `ci:golden`: конвейер ревью диспатчит Validate с `full=true` сам. Кандидат
|
||||
// беты и релиза собирается на `dev` — там трейлер работает как прежде.
|
||||
if (isTaskBranch(refName)) return false;
|
||||
return hasReleaseTrailer(headMessage);
|
||||
}
|
||||
|
||||
/** Ветка задачи — `issue/<NN>-…` (PROCESS §3). */
|
||||
export function isTaskBranch(refName) {
|
||||
return /^issue\//.test(String(refName || '').replace(/^refs\/heads\//, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужны ли мутанты по диффу (#510, сужено в #601). За 08–09.09 они съели 86 %
|
||||
* job-минут Validate, потому что бежали на каждом промежуточном пуше и
|
||||
@@ -159,6 +169,7 @@ if (invokedDirectly) {
|
||||
eventName: process.env.EVENT_NAME,
|
||||
headMessage: process.env.HEAD_MESSAGE,
|
||||
fullInput: process.env.FULL_INPUT,
|
||||
refName: process.env.REF_NAME,
|
||||
})}\n`);
|
||||
} else if (process.argv.includes('--heavy')) {
|
||||
// Отдельный вызов: у `heavy` другие входы (событие, сообщение head-коммита),
|
||||
@@ -167,6 +178,7 @@ if (invokedDirectly) {
|
||||
eventName: process.env.EVENT_NAME,
|
||||
headMessage: process.env.HEAD_MESSAGE,
|
||||
fullInput: process.env.FULL_INPUT,
|
||||
refName: process.env.REF_NAME,
|
||||
});
|
||||
process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`);
|
||||
const mutants = mutantsRequested({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
// Локальный гейт лёгкого трека одной командой (#479): `npm run gate:small`.
|
||||
//
|
||||
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
|
||||
// Этот файл — единственный источник состава обязательной части §8 (#701):
|
||||
// канон и конспекты его не переписывают, а называют. В #476 команды гонялись
|
||||
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
|
||||
// обязательная часть §8 начинается параллельно — сборка с typecheck, «новый
|
||||
// код не добавляет any», выбор смоков по диффу. Юниты читают свежий `dist`,
|
||||
|
||||
+65
-17
@@ -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 '';
|
||||
}
|
||||
@@ -132,6 +138,7 @@ export function realOps({
|
||||
now = Date.now, exec = sh,
|
||||
candidateTree = (sha) => githubCandidateTree({ repo, sha, token }),
|
||||
proofContext = (run) => loadGithubProofContext({ repo, run, token }),
|
||||
mutants = true,
|
||||
}) {
|
||||
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
|
||||
const git = (...args) => exec('git', args);
|
||||
@@ -144,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';
|
||||
},
|
||||
@@ -178,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;
|
||||
@@ -187,8 +205,8 @@ export function realOps({
|
||||
// Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза —
|
||||
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
|
||||
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
|
||||
dispatchValidate: (ref) => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
|
||||
dispatchValidate: (ref, { mutants: withMutants = mutants } = {}) => {
|
||||
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${withMutants ? 'true' : 'false'}`]);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
|
||||
@@ -208,7 +226,7 @@ export function realOps({
|
||||
try { context = await proofContext(run); }
|
||||
catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; }
|
||||
const verdict = evaluateCiProof({
|
||||
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge,
|
||||
run, ...context, candidate: { sha, tree }, policy: mutants ? CI_PROOF_POLICIES.merge : CI_PROOF_POLICIES.mergeLight,
|
||||
});
|
||||
if (verdict.status === 'green' || verdict.status === 'failed')
|
||||
return { result: verdict.status, url: verdict.url, note: verdict.note };
|
||||
@@ -234,7 +252,7 @@ export function realOps({
|
||||
/**
|
||||
* Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }.
|
||||
*/
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) {
|
||||
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS, mutants = true }) {
|
||||
ops.fetch('dev', branch);
|
||||
const actual = ops.revParse(`origin/${branch}`);
|
||||
const reviewedFresh = actual === material
|
||||
@@ -242,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 };
|
||||
};
|
||||
@@ -264,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');
|
||||
@@ -280,17 +307,36 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
tip = candidate;
|
||||
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
|
||||
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит
|
||||
// кандидат.
|
||||
//
|
||||
// #696: на треках show/ship мутантов нет, и лёгкий Validate на кандидате
|
||||
// уже запустил сам push выше — второй, dispatch-прогон, повторил бы его
|
||||
// целиком. Ждётся push-прогон; dispatch — только если его нет: push, в
|
||||
// котором сдвинулись одни docs/reviews/**, Validate не запускает
|
||||
// (paths-ignore), а отменённый concurrency прогон заменить некому.
|
||||
let result;
|
||||
let url;
|
||||
if (mutants) {
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));
|
||||
} else {
|
||||
ops.log(`лёгкий Validate на кандидате ${candidate.slice(0, 8)} — ждём push-прогон`);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));
|
||||
if (result === 'missing') {
|
||||
ops.log('push-прогона на кандидате нет — лёгкий dispatch');
|
||||
ops.dispatchValidate(branch);
|
||||
({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));
|
||||
}
|
||||
}
|
||||
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
|
||||
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -306,11 +352,13 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window
|
||||
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN;
|
||||
if (!branch || !material || !issue || !repo || !token) {
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name]; HP_PROCESS_TOKEN in env');
|
||||
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name] [--mutants=false]; HP_PROCESS_TOKEN in env');
|
||||
process.exit(2);
|
||||
}
|
||||
const ops = realOps({ repo, token, issue });
|
||||
mergeCandidate({ branch, material, issue, ops }).then((r) => {
|
||||
// #696: `--mutants=false` — треки show/ship сливаются по лёгкому Validate.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
const ops = realOps({ repo, token, issue, mutants });
|
||||
mergeCandidate({ branch, material, issue, ops, mutants }).then((r) => {
|
||||
const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`;
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
|
||||
process.stdout.write(out);
|
||||
|
||||
@@ -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 не размечен — добавьте причину в `
|
||||
|
||||
+350
-14
@@ -5494,8 +5494,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
|
||||
find: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));",
|
||||
replace: " ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = { result: 'green', url: 'skipped' }); // mutant: no validation",
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -5509,6 +5509,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',
|
||||
@@ -8146,6 +8158,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 +9063,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: '',
|
||||
}],
|
||||
@@ -10262,8 +10297,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'the digest must fail as soon as an anchor goes stale (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: '[§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)',
|
||||
replace: '[§5.1](../../PROCESS.md#51-короткий-трек)',
|
||||
find: '[§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)',
|
||||
replace: '[§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -10273,8 +10308,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'digest invented or a paraphrase nobody can check (#634 AC2)',
|
||||
patches: [{
|
||||
file: 'docs/process/AUTHOR.md',
|
||||
find: ' нечего ([§5.1](../../PROCESS.md#51-короткий-трек-метка-trivial)).',
|
||||
replace: ' нечего.',
|
||||
find: ' `track:show` ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)).',
|
||||
replace: ' `track:show`.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -10348,15 +10383,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',
|
||||
@@ -10613,7 +10715,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'остановиться, сохранить метку и назвать упавшую стадию',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
find: ' if [ "$REUSE" != "true" ] && [ "$SHIP" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then',
|
||||
replace: ' if false; then # mutant: every model result is accepted',
|
||||
}],
|
||||
},
|
||||
@@ -12129,10 +12231,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',
|
||||
@@ -12239,8 +12375,8 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'wait for that run, not for the push run that carries no mutants (#510 AC3)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch",
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' }));",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n ({ result, url } = await ops.waitValidate(candidate)); // mutant: push run, no dispatch",
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -12637,6 +12773,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',
|
||||
@@ -13334,6 +13504,172 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' if True: # mutant: concurrent flushes are not serialised\n',
|
||||
}],
|
||||
},
|
||||
// r1 #695: инфраструктура без трековой метки — трек show (§5.1).
|
||||
{
|
||||
id: 'guard-infra-keeps-ask-limit',
|
||||
guard: 'node --test --test-name-pattern="r1 #695" test/review-doc-guard.test.mjs',
|
||||
because: 'r1 #695: an infrastructure task without a track label reads as track:show (PROCESS §5.1); '
|
||||
+ 'the guard must give it the show cycle limit 2, not the ask limit 4',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: ' limit=2\n echo "инфраструктурная задача без трековой метки',
|
||||
replace: ' limit=4 # mutant: infra keeps the ask limit\n echo "инфраструктурная задача без трековой метки',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'packet-infra-track-ignores-show-default',
|
||||
guard: 'node --test --test-name-pattern="r1 #695" test/task-packet.test.mjs',
|
||||
because: 'r1 #695: the packet names the track an infrastructure task actually runs on — show '
|
||||
+ 'without a label, the owner label otherwise',
|
||||
patches: [{
|
||||
file: 'scripts/task-packet.mjs',
|
||||
find: " const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';",
|
||||
replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask',
|
||||
}],
|
||||
},
|
||||
// #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой.
|
||||
{
|
||||
id: 'track-show-pays-for-mutants',
|
||||
guard: 'node --test --test-name-pattern="мутанты по диффу — только ask" test/process-track.test.mjs',
|
||||
because: '#696: show/ship request no diff mutants before merge; only ask and the ci:mutants '
|
||||
+ 'label do — otherwise every small task pays the 28-minute mutant run again',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: " const mutants = track === 'ask' || labels.includes('ci:mutants');",
|
||||
replace: ' const mutants = true; // mutant: every track pays for mutants',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-limits-miss-new-src-file',
|
||||
guard: 'node --test --test-name-pattern="рамки ship: новые файлы" test/process-track.test.mjs',
|
||||
because: '#696: a new file in src/** is outside ship limits — ship merges without a model '
|
||||
+ 'review, so the mechanical limits are the only thing standing between it and dev',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (added.length) out.push(',
|
||||
replace: ' if (false && added.length) out.push(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-limit-off-by-one',
|
||||
guard: 'node --test --test-name-pattern="граница включительна" test/process-track.test.mjs',
|
||||
because: '#696: the ship limit is 30 src lines inclusive (owner decision); 30 lines still ship',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: ' if (lines > SHIP_SRC_LINE_LIMIT) out.push(',
|
||||
replace: ' if (lines >= SHIP_SRC_LINE_LIMIT) out.push(',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pipeline-ship-ignores-limits',
|
||||
guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs',
|
||||
because: '#696: ship skips the model only inside the limits; ignoring the limits merges '
|
||||
+ 'unread code of any size',
|
||||
patches: [{
|
||||
file: '.github/workflows/_process.yml',
|
||||
find: " if printf '%s\\n' \"$limits\" | grep -qx 'ship=true'; then",
|
||||
replace: ' if true; then # mutant: ship limits ignored',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'light-review-waits-running-push',
|
||||
guard: 'node --test --test-name-pattern="#696" test/validate-gate.test.mjs',
|
||||
because: '#696: a running push run cannot wake the round (process-resume listens to '
|
||||
+ 'dispatch only); treating it as proof candidate parks the round forever',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');",
|
||||
replace: " return run?.event === 'workflow_dispatch' || run?.event === 'push'; // mutant: running push counts",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'light-merge-dispatches-second-run',
|
||||
guard: 'node --test --test-name-pattern="слияние ждёт push-прогон" test/merge-candidate.test.mjs',
|
||||
because: '#696: the candidate push already starts the light Validate; a second dispatch '
|
||||
+ 'doubles runner minutes on every show/ship merge',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));",
|
||||
replace: " ops.dispatchValidate(branch); ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); // mutant: second run",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-ignores-merge-marker',
|
||||
guard: 'node --test --test-name-pattern="ship-задача — по маркеру" test/ship-review.test.mjs',
|
||||
because: '#696: the pipeline marker proves a merge without model review even when the '
|
||||
+ 'label was changed afterwards; missing it lets unread code into a beta',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: " return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));",
|
||||
replace: " return names.includes('track:ship'); // mutant: marker ignored",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-accepts-partial-coverage',
|
||||
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
|
||||
because: '#696: a ship task merged after the batch review ran was read by nobody; the beta '
|
||||
+ 'gate must refuse until the review is re-run',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: ' if (missing.length) {',
|
||||
replace: ' if (false && missing.length) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ship-review-accepts-high',
|
||||
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
|
||||
because: '#696: a High in the batch review is a defect a beta user would see; it blocks the beta',
|
||||
patches: [{
|
||||
file: 'scripts/ship-review.mjs',
|
||||
find: ' else if (block.high > 0) problems.push(',
|
||||
replace: ' else if (false && block.high > 0) problems.push(',
|
||||
}],
|
||||
},
|
||||
// #697: производные артефакты — на dev, тяжёлое на ветке задачи — по меткам.
|
||||
{
|
||||
id: 'task-branch-release-trailer-heavy-again',
|
||||
guard: 'node --test --test-name-pattern="#697" test/classify-changes.test.mjs',
|
||||
because: '#697: on a task branch the Release: trailer only marks a baseline acceptance; '
|
||||
+ 'it must not switch on smokes, golden and perf again — ci:full/ci:golden do that',
|
||||
patches: [{
|
||||
file: 'scripts/classify-changes.mjs',
|
||||
find: ' if (isTaskBranch(refName)) return false;\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ci-golden-label-does-not-order-full-set',
|
||||
guard: 'node --test --test-name-pattern="#697" test/process-track.test.mjs',
|
||||
because: '#697: a task that changes visuals on purpose sets ci:golden; without the full set '
|
||||
+ 'on its review material its shifted frames surface in the next task again',
|
||||
patches: [{
|
||||
file: 'scripts/process-track.mjs',
|
||||
find: " const full = labels.includes('ci:full') || labels.includes('ci:golden');",
|
||||
replace: " const full = labels.includes('ci:full'); // mutant: ci:golden ignored",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-gate-accepts-light-proof-for-full',
|
||||
guard: 'node --test --test-name-pattern="#697" test/validate-gate.test.mjs',
|
||||
because: '#697: with ci:full/ci:golden a light push run proves nothing about smokes and golden; '
|
||||
+ 'the gate must dispatch full=true instead of accepting it',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;",
|
||||
replace: ' const policy = base; // mutant: full never required',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'bot-golden-commit-without-provenance',
|
||||
guard: 'node --test --test-name-pattern="провенанс" test/beta-derived.test.mjs',
|
||||
because: '#697: the bot commit that moves baselines must carry Release: and Baseline-Reviewed:, '
|
||||
+ 'otherwise validate-commit-provenance turns dev red on the push',
|
||||
patches: [{
|
||||
file: '.github/workflows/beta-derived.yml',
|
||||
find: ' echo "Release: $TAG"\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
@@ -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,143 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696).
|
||||
*
|
||||
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
|
||||
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
|
||||
*
|
||||
* `resolve` печатает `track=ship|show|ask`, `mutants=true|false` и
|
||||
* `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит
|
||||
* заход: мутанты по диффу нужны только `ask` и метке `ci:mutants`; полный
|
||||
* набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и
|
||||
* `ci:golden` (#697). Инфраструктурная задача без трековой
|
||||
* метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в
|
||||
* диффе ни одного файла класса A.
|
||||
*
|
||||
* `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое
|
||||
* нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в
|
||||
* `src/**`, без i18n, без полей конфига и без Python. Рамки механические
|
||||
* намеренно: по ним конвейер сливает задачу без ревью модели, и решать их
|
||||
* «на глаз» некому.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { appendFileSync } from 'node:fs';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
|
||||
export const SHIP_SRC_LINE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small`
|
||||
* читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека
|
||||
* не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может
|
||||
* поставить `track:*`, чтобы задать цену конвейера.
|
||||
*/
|
||||
export function trackFromLabels(labels = []) {
|
||||
if (labels.includes('track:ship')) return 'ship';
|
||||
if (labels.includes('track:show')) return 'show';
|
||||
if (labels.includes('track:ask')) return 'ask';
|
||||
if (labels.includes('trivial') || labels.includes('small')) return 'show';
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/** Есть ли у задачи трековая метка вообще — новая или прежняя. */
|
||||
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
|
||||
.some((label) => labels.includes(label));
|
||||
|
||||
/**
|
||||
* Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё
|
||||
* инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие —
|
||||
* `ask`. Мутанты по диффу — только на `ask` или по метке `ci:mutants`.
|
||||
* Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697).
|
||||
*/
|
||||
export function resolveTrack({ labels = [], files = [] } = {}) {
|
||||
const infrastructure = files.length > 0 && files.every((file) => classify(file) !== 'A');
|
||||
const track = hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask');
|
||||
const mutants = track === 'ask' || labels.includes('ci:mutants');
|
||||
const full = labels.includes('ci:full') || labels.includes('ci:golden');
|
||||
return { track, mutants, full, infrastructure };
|
||||
}
|
||||
|
||||
const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//];
|
||||
const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/];
|
||||
const PYTHON = /\.py$/;
|
||||
|
||||
/**
|
||||
* Нарушения рамок `ship` по `git diff --numstat` и `--name-status` базы и
|
||||
* вершины. Пустой список — задача укладывается в рамки.
|
||||
*
|
||||
* @param {{added:number|null, deleted:number|null, path:string}[]} numstat
|
||||
* @param {{status:string, path:string}[]} nameStatus
|
||||
*/
|
||||
export function shipLimitViolations({ numstat = [], nameStatus = [] } = {}) {
|
||||
const out = [];
|
||||
const src = numstat.filter((row) => row.path.startsWith('src/'));
|
||||
const binary = src.filter((row) => row.added === null || row.deleted === null);
|
||||
const lines = src.reduce((sum, row) => sum + (row.added ?? 0) + (row.deleted ?? 0), 0);
|
||||
if (lines > SHIP_SRC_LINE_LIMIT) out.push(`дифф src/** — ${lines} строк при рамке ${SHIP_SRC_LINE_LIMIT}`);
|
||||
if (binary.length) out.push(`двоичные файлы в src/**: ${binary.map((row) => row.path).join(', ')}`);
|
||||
const added = nameStatus.filter((row) => row.status.startsWith('A') && row.path.startsWith('src/'));
|
||||
if (added.length) out.push(`новые файлы в src/**: ${added.map((row) => row.path).join(', ')}`);
|
||||
const paths = [...new Set([...numstat, ...nameStatus].map((row) => row.path))];
|
||||
const i18n = paths.filter((path) => I18N.some((re) => re.test(path)));
|
||||
if (i18n.length) out.push(`ключи i18n: ${i18n.join(', ')}`);
|
||||
const config = paths.filter((path) => CONFIG.some((re) => re.test(path)));
|
||||
if (config.length) out.push(`поля конфига: ${config.join(', ')}`);
|
||||
const python = paths.filter((path) => PYTHON.test(path));
|
||||
if (python.length) out.push(`Python: ${python.join(', ')}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` → строки; двоичный файл даёт `-\t-`. */
|
||||
export function parseNumstat(text = '') {
|
||||
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
||||
const [added, deleted, ...rest] = line.split('\t');
|
||||
return { added: added === '-' ? null : Number(added), deleted: deleted === '-' ? null : Number(deleted), path: rest.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
/** `git diff --name-status` → строки; у переименования путь — новый. */
|
||||
export function parseNameStatus(text = '') {
|
||||
return String(text).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => {
|
||||
const [status, ...paths] = line.split('\t');
|
||||
return { status, path: paths.at(-1) };
|
||||
});
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`);
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
||||
const emit = (lines) => {
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
};
|
||||
try {
|
||||
const base = value('base');
|
||||
const head = value('head') || 'HEAD';
|
||||
const range = base ? `${base}...${head}` : null;
|
||||
if (command === 'resolve') {
|
||||
const labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const files = range ? git(['diff', '--name-only', range]).split('\n').filter(Boolean) : [];
|
||||
const { track, mutants, full } = resolveTrack({ labels, files });
|
||||
emit([`track=${track}`, `mutants=${mutants}`, `full=${full}`]);
|
||||
} else if (command === 'ship-limits') {
|
||||
if (!range) throw new Error('--base is required');
|
||||
const violations = shipLimitViolations({
|
||||
numstat: parseNumstat(git(['diff', '--numstat', range])),
|
||||
nameStatus: parseNameStatus(git(['diff', '--name-status', range])),
|
||||
});
|
||||
emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]);
|
||||
} else {
|
||||
throw new Error('usage: process-track.mjs resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -503,11 +503,18 @@ if (invokedDirectly) {
|
||||
const bundleSha256 = bundleSnapshot.entrySha256;
|
||||
const validateRuns = await assertGreenValidate(sha);
|
||||
validateIssues();
|
||||
// #696, PROCESS.md §11.7: ship-задачи диапазона слиты без ревью модели —
|
||||
// бета только после пакетного ревью их кода (`ship-review.yml`).
|
||||
run(process.execPath, [
|
||||
'scripts/ship-review.mjs', 'check', `--tag=${tag}`, `--candidate=${sha}`, `--repo=${repo}`,
|
||||
], { inherit: true });
|
||||
const history = readCandidateHistory(sha);
|
||||
const generatedMembership = buildReleaseMembership({
|
||||
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,
|
||||
|
||||
@@ -88,9 +88,11 @@ export function archivePlan({ names, lines, open, through, addedIn = new Map() }
|
||||
if (name === INDEX_FILE) continue;
|
||||
const doc = parseDocName(name);
|
||||
if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; }
|
||||
if (doc.stage === 'release') {
|
||||
if (tags.has(doc.tag)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${doc.tag}/${name}`, tag: doc.tag, issue: null });
|
||||
else kept.push({ name, reason: `ревью линии ${doc.tag} не входит в архивируемые линии` });
|
||||
if (doc.stage === 'release' || doc.stage === 'ship') {
|
||||
// #696: пакетное ревью беты уходит в каталог своей стабильной линии.
|
||||
const line = doc.tag.replace(/-beta\.\d+$/, '');
|
||||
if (tags.has(line)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${line}/${name}`, tag: line, issue: null });
|
||||
else kept.push({ name, reason: `ревью линии ${line} не входит в архивируемые линии` });
|
||||
continue;
|
||||
}
|
||||
if (openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; }
|
||||
|
||||
@@ -28,6 +28,8 @@ import { isMainModule } from './spawn-portable.mjs';
|
||||
export const INDEX_FILE = 'INDEX.md';
|
||||
const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i;
|
||||
const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i;
|
||||
// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты.
|
||||
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)\.md$/i;
|
||||
const COLOUR = {
|
||||
'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный',
|
||||
'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый',
|
||||
@@ -43,6 +45,8 @@ const VERDICT_OWN_LINE_RE = /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)[^\
|
||||
export function parseDocName(name) {
|
||||
const release = RELEASE_DOC_NAME.exec(String(name));
|
||||
if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] };
|
||||
const ship = SHIP_DOC_NAME.exec(String(name));
|
||||
if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1] };
|
||||
const match = DOC_NAME.exec(String(name));
|
||||
if (!match) return null;
|
||||
return {
|
||||
@@ -301,14 +305,20 @@ export function assertAllDocumentsIndexed({ skipped }) {
|
||||
const badge = (verdict) => ({ 'зелёный': '🟢', 'жёлтый': '🟡', 'красный': '🔴' }[verdict] || '⚪');
|
||||
|
||||
export function renderIndex({ entries, skipped = [] }) {
|
||||
const releaseDocs = entries.filter((entry) => entry.stage === 'release').sort((a, b) => {
|
||||
const av = a.tag.slice(1).split('.').map(Number);
|
||||
const bv = b.tag.slice(1).split('.').map(Number);
|
||||
for (let i = 0; i < 3; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
return 0;
|
||||
// Документы линии и беты (release, ship) — без issue; свежий тег выше.
|
||||
// Стабильный тег старше своих бет: `v1.79.0` выше `v1.79.0-beta.3`.
|
||||
const tagKey = (tag) => {
|
||||
const [core, beta] = tag.slice(1).split('-beta.');
|
||||
return [...core.split('.').map(Number), beta == null ? Infinity : Number(beta)];
|
||||
};
|
||||
const releaseDocs = entries.filter((entry) => entry.issue == null).sort((a, b) => {
|
||||
const av = tagKey(a.tag);
|
||||
const bv = tagKey(b.tag);
|
||||
for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
|
||||
return a.stage.localeCompare(b.stage);
|
||||
});
|
||||
const byIssue = new Map();
|
||||
for (const entry of entries.filter((item) => item.stage !== 'release')) {
|
||||
for (const entry of entries.filter((item) => item.issue != null)) {
|
||||
const list = byIssue.get(entry.issue) || [];
|
||||
list.push(entry);
|
||||
byIssue.set(entry.issue, list);
|
||||
@@ -322,7 +332,8 @@ export function renderIndex({ entries, skipped = [] }) {
|
||||
lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |');
|
||||
lines.push('|---|---|---|---|---:|---:|---|---|');
|
||||
for (const doc of releaseDocs) {
|
||||
lines.push(`| линия ${doc.tag} | [${doc.name}](${doc.name}) | ревью линии · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
|
||||
const [who, what] = doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии'];
|
||||
lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
|
||||
}
|
||||
for (const issue of issues) {
|
||||
const docs = byIssue.get(issue).sort((a, b) => (a.stage === b.stage ? (a.round || 0) - (b.round || 0) : a.stage === 'spec' ? -1 : 1));
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Пакетное ревью задач `track:ship` перед бетой (#696, PROCESS.md §11.7).
|
||||
*
|
||||
* node scripts/ship-review.mjs doc --tag=v1.79.0-beta.1
|
||||
* node scripts/ship-review.mjs prepare --tag=<тег> --candidate=<sha> --out=<dir> [--repo=owner/name]
|
||||
* node scripts/ship-review.mjs check --tag=<тег> --candidate=<sha> [--repo=owner/name]
|
||||
*
|
||||
* `ship` сливается без ревью модели (§5): правка в механических рамках и
|
||||
* зелёный лёгкий Validate. Прочитать её код обязан кто-то до того, как она
|
||||
* уйдёт пользователям, — это пакетное ревью всех ship-задач диапазона
|
||||
* «прошлый тег..кандидат беты» одной сессией модели.
|
||||
*
|
||||
* Какие задачи — ship, доказывает конвейер, а не метка: слияние без модели
|
||||
* оставляет в issue машинный маркер `<!-- hp:ship-merge material=<sha> -->`.
|
||||
* Метка `track:ship` на issue в диапазоне тоже включает задачу в пакет —
|
||||
* лишний разбор дешевле пропущенного. Состав диапазона — трейлеры `Issue: #NN`,
|
||||
* тот же построитель, что у `RELEASE-MEMBERSHIP.json` (#547).
|
||||
*
|
||||
* `check` — гейт публикации беты: если в диапазоне есть ship-задачи, документ
|
||||
* `docs/reviews/SHIP-REVIEW-<тег>.md` обязан быть в кандидате или в `dev`,
|
||||
* покрывать их все машинным блоком и не нести High.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { issueTrailers, readCandidateHistory } from './release-membership.mjs';
|
||||
|
||||
export const SHIP_REVIEW_DIR = 'docs/reviews';
|
||||
export const SHIP_MERGE_MARKER_RE = /<!-- hp:ship-merge material=([0-9a-f]{40}) -->/;
|
||||
export const SHIP_REVIEW_ANCHOR = '<!-- hp-ship-review-anchors -->';
|
||||
export const RELEASE_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-beta\.(0|[1-9]\d*))?$/;
|
||||
const SHA_RE = /^[0-9a-f]{40,64}$/;
|
||||
const TZ_LIMIT = 1500;
|
||||
|
||||
/** Путь документа пакетного ревью для тега беты или стабильного. */
|
||||
export function shipReviewDocPath(tag) {
|
||||
if (!RELEASE_TAG_RE.test(String(tag))) throw new Error(`not a release tag: ${tag}`);
|
||||
return `${SHIP_REVIEW_DIR}/SHIP-REVIEW-${tag}.md`;
|
||||
}
|
||||
|
||||
/** Задача — ship, если конвейер слил её без модели или на ней стоит `track:ship`. */
|
||||
export function isShipIssue({ labels = [], comments = [] } = {}) {
|
||||
const names = labels.map((label) => (typeof label === 'string' ? label : label?.name));
|
||||
return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));
|
||||
}
|
||||
|
||||
/** Раздел `## ТЗ` тела issue — для ship это строка «что меняется и чем проверить». */
|
||||
export function specSection(body = '') {
|
||||
const text = String(body);
|
||||
const start = text.search(/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu);
|
||||
if (start < 0) return '';
|
||||
const rest = text.slice(start).split('\n');
|
||||
const out = [rest[0]];
|
||||
for (const line of rest.slice(1)) {
|
||||
if (/^#{1,3}\s/.test(line)) break;
|
||||
out.push(line);
|
||||
}
|
||||
const section = out.join('\n').trim();
|
||||
return section.length > TZ_LIMIT ? `${section.slice(0, TZ_LIMIT)}…` : section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ship-задачи диапазона: номера из трейлеров коммитов, признак ship — из
|
||||
* данных issue. `issueData(number)` → `{ title, body, labels, comments }` или null.
|
||||
*/
|
||||
export function shipIssuesInRange({ commits = [], issueData }) {
|
||||
const byIssue = new Map();
|
||||
for (const commit of commits) {
|
||||
for (const number of issueTrailers(commit.message)) {
|
||||
if (!byIssue.has(number)) byIssue.set(number, []);
|
||||
byIssue.get(number).push({ sha: commit.sha, subject: String(commit.message).split('\n')[0] });
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (const [number, list] of [...byIssue].sort((a, b) => a[0] - b[0])) {
|
||||
const data = issueData(number);
|
||||
if (!data || !isShipIssue(data)) continue;
|
||||
out.push({ number, title: data.title || '', spec: specSection(data.body), commits: list.reverse() });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) {
|
||||
const lines = [
|
||||
`# Вход пакетного ревью ship ${tag}`,
|
||||
'',
|
||||
`- Кандидат: \`${candidate}\``,
|
||||
`- Диапазон: ${base ? `\`${base.tag}\` · \`${base.sha}\`` : 'нет прошлого тега — всё дерево'} .. кандидат`,
|
||||
...(runUrl ? [`- Прогон: ${runUrl}`] : []),
|
||||
`- Документ: \`${shipReviewDocPath(tag)}\``,
|
||||
'',
|
||||
`## Задачи ship (${ship.length}) — слиты без ревью модели`,
|
||||
'',
|
||||
];
|
||||
for (const issue of ship) {
|
||||
lines.push(`### #${issue.number} · ${issue.title}`, '');
|
||||
lines.push(issue.spec ? issue.spec : '(раздела «## ТЗ» в теле нет — ТЗ задачи не записано, это находка)', '');
|
||||
lines.push('Коммиты (`git show <sha>`):', '');
|
||||
for (const commit of issue.commits) lines.push(`- \`${commit.sha}\` ${commit.subject}`);
|
||||
lines.push('');
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
||||
}
|
||||
|
||||
/** Машинный блок документа: его пишет публикация, читает `check`. */
|
||||
export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '' }) {
|
||||
return [
|
||||
SHIP_REVIEW_ANCHOR,
|
||||
'### Материал пакетного ревью',
|
||||
'',
|
||||
'```',
|
||||
`tag ${tag}`,
|
||||
`candidate ${candidate}`,
|
||||
`base ${base || '—'}`,
|
||||
`issues ${issues.length ? issues.join(',') : '—'}`,
|
||||
`high ${high}`,
|
||||
`medium ${medium}`,
|
||||
`low ${low}`,
|
||||
`run ${runUrl || '—'}`,
|
||||
'```',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function parseAnchorBlock(text = '') {
|
||||
const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR);
|
||||
if (at < 0) return null;
|
||||
const fence = /```\n([\s\S]*?)\n```/.exec(String(text).slice(at));
|
||||
if (!fence) return null;
|
||||
const fields = Object.fromEntries(fence[1].split('\n').map((line) => {
|
||||
const space = line.indexOf(' ');
|
||||
return space < 0 ? [line, ''] : [line.slice(0, space), line.slice(space + 1).trim()];
|
||||
}));
|
||||
const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null);
|
||||
return {
|
||||
tag: fields.tag || null,
|
||||
candidate: fields.candidate || null,
|
||||
issues: String(fields.issues || '').split(',').map((s) => number(s.trim())).filter((n) => n != null),
|
||||
high: number(fields.high),
|
||||
medium: number(fields.medium),
|
||||
low: number(fields.low),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Причины не публиковать бету; пустой список — гейт пройден. Без ship-задач в
|
||||
* диапазоне документ не нужен: пакетному ревью нечего читать.
|
||||
*/
|
||||
export function shipReviewProblems({ tag, ship = [], docText = null }) {
|
||||
if (!ship.length) return [];
|
||||
const doc = shipReviewDocPath(tag);
|
||||
const numbers = ship.map((issue) => issue.number);
|
||||
const run = `gh workflow run ship-review.yml --ref dev -f tag=${tag}`;
|
||||
if (!docText) {
|
||||
return [`${doc} нет ни в кандидате, ни в dev: ship-задачи ${numbers.map((n) => `#${n}`).join(', ')} слиты без ревью модели, и их код до беты не читал никто (PROCESS.md §11.7). Запустить: ${run}`];
|
||||
}
|
||||
const block = parseAnchorBlock(docText);
|
||||
if (!block) return [`${doc} без машинного блока ${SHIP_REVIEW_ANCHOR}: покрытие задач не доказано. Переснять: ${run} -f force=true`];
|
||||
const problems = [];
|
||||
if (block.tag !== tag) problems.push(`${doc} записан для тега ${block.tag}, а публикуется ${tag}`);
|
||||
const missing = numbers.filter((n) => !block.issues.includes(n));
|
||||
if (missing.length) {
|
||||
problems.push(`${doc} не покрывает ship-задачи ${missing.map((n) => `#${n}`).join(', ')} — они слиты после ревью. Переснять: ${run} -f force=true`);
|
||||
}
|
||||
if (block.high == null) problems.push(`${doc}: в машинном блоке нет числа High`);
|
||||
else if (block.high > 0) problems.push(`${doc}: High ${block.high} — бета ждёт починки: находка чинится отдельной задачей, затем ревью переснимается`);
|
||||
return problems;
|
||||
}
|
||||
|
||||
function git(args, { allowFailure = false } = {}) {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||||
if (r.status !== 0 && !allowFailure) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || '').trim()}`);
|
||||
return r.status === 0 ? r.stdout : null;
|
||||
}
|
||||
|
||||
function ghIssue(repo, number) {
|
||||
const r = spawnSync('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,labels,comments'], {
|
||||
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (r.status !== 0) throw new Error(`gh issue view ${number}: ${(r.stderr || r.stdout || '').trim()}`);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
/** Документ — из кандидата, иначе из `origin/dev`: ревью могло лечь в dev после Release-коммита. */
|
||||
export function readShipDoc(tag, candidate) {
|
||||
const path = shipReviewDocPath(tag);
|
||||
return git(['show', `${candidate}:${path}`], { allowFailure: true })
|
||||
?? git(['show', `origin/dev:${path}`], { allowFailure: true });
|
||||
}
|
||||
|
||||
function collect({ tag, candidate, repo }) {
|
||||
shipReviewDocPath(tag);
|
||||
if (!SHA_RE.test(String(candidate))) throw new Error(`invalid candidate SHA: ${candidate}`);
|
||||
if (!repo) throw new Error('--repo (или GITHUB_REPOSITORY) обязателен');
|
||||
const { base, commits } = readCandidateHistory(candidate);
|
||||
const ship = shipIssuesInRange({ commits, issueData: (number) => ghIssue(repo, number) });
|
||||
return { base, ship };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
const [command, ...rest] = process.argv.slice(2);
|
||||
const value = (name) => rest.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
|
||||
const tag = value('tag');
|
||||
const repo = value('repo') || process.env.GITHUB_REPOSITORY || '';
|
||||
if (command === 'doc') {
|
||||
console.log(shipReviewDocPath(tag));
|
||||
} else if (command === 'prepare') {
|
||||
const candidate = value('candidate');
|
||||
const out = resolve(value('out') || '.');
|
||||
const { base, ship } = collect({ tag, candidate, repo });
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(join(out, 'ship-issues.json'), `${JSON.stringify({ schema: 1, tag, candidate, base, issues: ship }, null, 2)}\n`);
|
||||
writeFileSync(join(out, 'brief.md'), renderShipBrief({ tag, candidate, base, ship, runUrl: value('run-url') }));
|
||||
console.log(`doc=${shipReviewDocPath(tag)}`);
|
||||
console.log(`base=${base ? base.tag : ''}`);
|
||||
console.log(`issues=${ship.map((issue) => issue.number).join(',')}`);
|
||||
} else if (command === 'check') {
|
||||
const candidate = value('candidate');
|
||||
const { ship } = collect({ tag, candidate, repo });
|
||||
const problems = shipReviewProblems({ tag, ship, docText: readShipDoc(tag, candidate) });
|
||||
if (!ship.length) console.log('ship-задач в диапазоне нет — пакетное ревью не требуется');
|
||||
else if (!problems.length) console.log(`пакетное ревью ${shipReviewDocPath(tag)} покрывает ship-задачи ${ship.map((i) => `#${i.number}`).join(', ')}`);
|
||||
for (const problem of problems) console.error(`::error::${problem}`);
|
||||
process.exit(problems.length ? 1 : 0);
|
||||
} else {
|
||||
throw new Error('usage: ship-review.mjs doc --tag=<tag> | prepare --tag=<tag> --candidate=<sha> --out=<dir> [--repo=o/r] | check --tag=<tag> --candidate=<sha> [--repo=o/r]');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`::error::${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -20,6 +20,7 @@ import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
|
||||
|
||||
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
|
||||
|
||||
@@ -40,8 +41,8 @@ export function rightsFor(status, labels = [], { infrastructure = false, infrast
|
||||
lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)');
|
||||
}
|
||||
switch (status) {
|
||||
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки метками, критерий лёгкого трека, затем ТЗ'); break;
|
||||
case 'S2-analysis': lines.push('следующий шаг: ТЗ (S3); трек по умолчанию small — отказ от него обосновать названным критерием §5'); break;
|
||||
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки и трек метками (по умолчанию track:show); на track:ship — строка «что меняется и чем проверить» под «## ТЗ», затем S5'); break;
|
||||
case 'S2-analysis': lines.push('следующий шаг: track:show — до трёх AC под «## ТЗ», затем S5; track:ask — ТЗ (S3) с названным критерием §5'); break;
|
||||
case 'S3-spec': lines.push('следующий шаг: ТЗ готово → push ветки → метка S4-spec-review (метку после push)'); break;
|
||||
case 'S4-spec-review': lines.push('идёт ревью ТЗ: ждать вердикт (scripts/wait-verdict.mjs), не править материал'); break;
|
||||
case 'S5-ready': lines.push('следующий шаг: ветка issue/NN-slug от dev, код по ТЗ, метка S6-in-progress'); break;
|
||||
@@ -126,6 +127,9 @@ export function branchIsInfrastructure(changedFiles = []) {
|
||||
|
||||
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
|
||||
|
||||
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
|
||||
export { hasTrackLabel, trackFromLabels };
|
||||
|
||||
/**
|
||||
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
|
||||
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
|
||||
@@ -162,10 +166,14 @@ export function buildPacket(inputs) {
|
||||
// продуктового потока.
|
||||
const infrastructure = branch?.infrastructure === true && productFlow.length === 0;
|
||||
const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0;
|
||||
// §5.1 (r1 #695): инфраструктурная задача без трековой метки идёт как
|
||||
// `show`; явная метка владельца главнее. Маршрут при этом остаётся
|
||||
// инфраструктурным — вход сразу на S7, без S1–S5.
|
||||
const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';
|
||||
const track = infrastructure
|
||||
? 'инфраструктурный'
|
||||
: infrastructureHint ? 'инфраструктурный (предварительно; подтвердить путями/diff)'
|
||||
: labels.includes('trivial') ? 'trivial' : labels.includes('small') ? 'small' : 'полный';
|
||||
? `инфраструктурный · ${infraTrack}`
|
||||
: infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)`
|
||||
: trackFromLabels(labels);
|
||||
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
|
||||
const verdict = lastVerdict(comments, reviewDocs, stage);
|
||||
// ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до
|
||||
|
||||
@@ -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('\\', '/'));
|
||||
|
||||
+41
-12
@@ -7,7 +7,7 @@
|
||||
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
|
||||
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
|
||||
*
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
|
||||
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait] [--mutants=false]
|
||||
*
|
||||
* Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в
|
||||
* $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending.
|
||||
@@ -45,6 +45,19 @@ export function isMutantRun(run) {
|
||||
return run?.event === 'workflow_dispatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* #696: какие прогоны годятся в доказательство. С мутантами — только dispatch
|
||||
* (push их не запрашивает). Без мутантов (`show`/`ship`) годится и
|
||||
* завершённый push-прогон на материале: лёгкий Validate на этом SHA уже
|
||||
* исполнен, и dispatch повторил бы его. Ждать же можно только dispatch:
|
||||
* продолжение раунда будит его завершение (process-resume.yml слушает
|
||||
* `workflow_dispatch`), и push, который ещё идёт, раунд не разбудил бы.
|
||||
*/
|
||||
export function proofCandidate(run, { mutants = true } = {}) {
|
||||
if (mutants) return isMutantRun(run);
|
||||
return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');
|
||||
}
|
||||
|
||||
// #622: префикс — из контракта ci-proof (JOB_RULES.mutants), который сверяется
|
||||
// с validate.yml; своей строки здесь больше нет.
|
||||
export { MUTANT_JOB_PREFIX };
|
||||
@@ -72,7 +85,14 @@ export function provesMutants(jobs) {
|
||||
*/
|
||||
export async function validateGate({
|
||||
ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true,
|
||||
mutants = true, full = false,
|
||||
}) {
|
||||
// #697: метки `ci:full`/`ci:golden` заказывают полный набор на материале —
|
||||
// смоки, golden, perf. Лёгкий push-прогон его не несёт и доказательством не
|
||||
// считается (policy.full), поэтому гейт диспатчит `full=true` сам.
|
||||
const base = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
|
||||
const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;
|
||||
const label = `${mutants ? 'Validate с мутантами' : 'Validate'}${full ? ' (полный набор)' : ''}`;
|
||||
const started = ops.now();
|
||||
const candidateTree = await ops.candidateTree(sha);
|
||||
const ignored = new Set(); // завершённые dispatch без применимого proof
|
||||
@@ -80,14 +100,19 @@ export async function validateGate({
|
||||
let dispatchedAt = null;
|
||||
let attempts = 0;
|
||||
while (ops.now() - started < totalMs) {
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId));
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0];
|
||||
const runs = (await ops.listRuns(sha)).filter((x) => proofCandidate(x, { mutants }) && !ignored.has(x.databaseId));
|
||||
// Без мутантов завершённый прогон на материале предпочтительнее идущего
|
||||
// dispatch: доказательство уже есть, ждать нечего (#696). С мутантами
|
||||
// порядок прежний — свежий прогон решает.
|
||||
const run = runs.find((x) => tracked && x.databaseId === tracked)
|
||||
|| (!mutants && runs.find((x) => x.status === 'completed'))
|
||||
|| runs[0];
|
||||
if (run) {
|
||||
tracked = run.databaseId;
|
||||
if (run.status === 'completed') {
|
||||
const context = await ops.proof(run);
|
||||
const verdict = evaluateCiProof({
|
||||
run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review,
|
||||
run, ...context, candidate: { sha, tree: candidateTree }, policy,
|
||||
});
|
||||
if (verdict.status === 'green') return { result: 'green', url: verdict.url, note: verdict.note };
|
||||
if (verdict.status === 'failed') return { result: 'failed', url: verdict.url, note: verdict.note };
|
||||
@@ -100,11 +125,11 @@ export async function validateGate({
|
||||
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
|
||||
return {
|
||||
result: 'pending', url: run.url || null, runId: run.databaseId,
|
||||
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
note: `${label} идёт (${run.status}); продолжение — по завершении прогона`,
|
||||
};
|
||||
}
|
||||
} else if (dispatchedAt === null) {
|
||||
await ops.dispatch(ref);
|
||||
await ops.dispatch(ref, { mutants, full });
|
||||
dispatchedAt = ops.now();
|
||||
attempts = 1;
|
||||
} else if (ops.now() - dispatchedAt > appearMs) {
|
||||
@@ -116,7 +141,7 @@ export async function validateGate({
|
||||
// чужой коммит переживёт и вторую попытку.
|
||||
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
|
||||
if (elsewhere && attempts < DISPATCH_ATTEMPTS) {
|
||||
await ops.dispatch(ref);
|
||||
await ops.dispatch(ref, { mutants, full });
|
||||
dispatchedAt = ops.now();
|
||||
attempts += 1;
|
||||
await ops.sleep(pollMs);
|
||||
@@ -131,7 +156,7 @@ export async function validateGate({
|
||||
}
|
||||
await ops.sleep(pollMs);
|
||||
}
|
||||
return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
|
||||
return { result: 'failed', url: null, note: `${label} не завершился за 45 минут` };
|
||||
}
|
||||
|
||||
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
|
||||
@@ -151,8 +176,8 @@ export function realOps({ repo, workflow = 'validate.yml', token = process.env.G
|
||||
catch { return { proof: null, jobs: [], reuseRuns: new Map() }; }
|
||||
},
|
||||
listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])),
|
||||
dispatch: async (ref) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
|
||||
dispatch: async (ref, { mutants = true, full = false } = {}) => {
|
||||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', `full=${full ? 'true' : 'false'}`, '-f', `mutants=${mutants ? 'true' : 'false'}`]);
|
||||
if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
||||
@@ -167,11 +192,15 @@ if (invokedDirectly) {
|
||||
const ref = arg('ref');
|
||||
const sha = arg('sha');
|
||||
if (!repo || !ref || !sha) {
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml]');
|
||||
console.error('usage: validate-gate.mjs --repo=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml] [--mutants=false] [--full=true] [--no-wait]');
|
||||
process.exit(2);
|
||||
}
|
||||
const wait = !process.argv.includes('--no-wait');
|
||||
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
// #696: `--mutants=false` — лёгкое доказательство треков show/ship.
|
||||
const mutants = arg('mutants') !== 'false';
|
||||
// #697: `--full=true` — метки ci:full/ci:golden.
|
||||
const full = arg('full') === 'true';
|
||||
const outcome = await validateGate({ ref, sha, wait, mutants, full, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `note=${outcome.note}`];
|
||||
for (const line of lines) console.log(line);
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// #697, PROCESS.md §8: отпечаток и кадры скриншотов, эталоны golden —
|
||||
// одним коммитом бота на dev перед бетой, а не в каждой ветке задачи.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateCommitMessage } from '../scripts/validate-commit-provenance.mjs';
|
||||
import { isCandidateSubject } from '../scripts/bundle-policy.mjs';
|
||||
|
||||
const WORKFLOW = readFileSync(fileURLToPath(new URL('../.github/workflows/beta-derived.yml', import.meta.url)), 'utf8');
|
||||
const step = (name) => {
|
||||
const start = WORKFLOW.indexOf(` - name: ${name}\n`);
|
||||
assert.ok(start > 0, `нет шага ${name}`);
|
||||
const next = WORKFLOW.indexOf('\n - ', start + 10);
|
||||
return next < 0 ? WORKFLOW.slice(start) : WORKFLOW.slice(start, next);
|
||||
};
|
||||
|
||||
test('#697 бот: только по кнопке, прав на запись у job нет — пишет PAT одним push', () => {
|
||||
assert.match(WORKFLOW, /^on:\n workflow_dispatch:\n/m);
|
||||
assert.doesNotMatch(WORKFLOW, /^\s+(push|schedule|workflow_run):/m);
|
||||
assert.match(WORKFLOW, /permissions:\n\s+contents: read\n\s+actions: read\n/);
|
||||
assert.doesNotMatch(WORKFLOW, /contents: write/);
|
||||
const commit = step('Коммит в dev');
|
||||
assert.match(commit, /git push -q "https:\/\/x-access-token:\$TOKEN@github\.com\/\$\{\{ github\.repository \}\}" HEAD:dev/);
|
||||
assert.doesNotMatch(commit, /--force/, 'ушедший dev — перезапуск, а не перезапись');
|
||||
});
|
||||
|
||||
test('#697 бот: скриншоты принимаются по канону, изменённый кадр — только объявленный', () => {
|
||||
const docs = step('Кадры документации — съёмка и приёмка');
|
||||
assert.match(docs, /node demo\/docs\/capture\.mjs --stability=3/);
|
||||
assert.match(docs, /git checkout -- docs\/images\n\s+git clean -fdq -- docs\/images/, 'приёмка сравнивает с закоммиченными кадрами');
|
||||
assert.match(docs, /args=\(--reviewed "--from=\$cand"\)/);
|
||||
assert.match(docs, /--expect-change=\$EXPECT/);
|
||||
assert.match(docs, /EXPECT: \$\{\{ inputs\.docs_expect_change \}\}/);
|
||||
assert.match(WORKFLOW, /OXIPNG_VERSION: 10\.2\.0/, 'тот же упаковщик, что docs-screenshots.yml');
|
||||
const screenshots = readFileSync(fileURLToPath(new URL('../.github/workflows/docs-screenshots.yml', import.meta.url)), 'utf8');
|
||||
assert.match(screenshots, /OXIPNG_VERSION: 10\.2\.0/);
|
||||
});
|
||||
|
||||
test('#697 бот: эталоны golden — только из завершённого Validate на dev', () => {
|
||||
const golden = step('Эталоны golden из прогона Validate');
|
||||
assert.match(golden, /if: inputs\.golden_run != ''/);
|
||||
assert.match(golden, /\[ "\$path" != "\.github\/workflows\/validate\.yml" \] \|\| \[ "\$branch" != "dev" \] \|\| \[ "\$status" != "completed" \]/);
|
||||
assert.match(golden, /gh run download "\$RUN" --repo "\$\{\{ github\.repository \}\}" -n golden-images/);
|
||||
assert.match(golden, /node scripts\/golden-accept\.mjs "\$\{args\[@\]\}"/);
|
||||
});
|
||||
|
||||
test('#697 бот: сообщение коммита проходит провенанс и не выдаёт себя за кандидата', () => {
|
||||
const commit = step('Коммит в dev');
|
||||
assert.match(commit, /if \[ "\$GOLDEN_CHANGED" = "true" \]; then\n\s+echo "Release: \$TAG"\n\s+echo "Baseline-Reviewed: \$GOLDEN_URL"/,
|
||||
'эталоны без Release: и Baseline-Reviewed: провенанс отклонит');
|
||||
assert.match(commit, /echo "Issue: #697"\n\s+echo "User-Visible: no"/);
|
||||
const subject = 'docs: accept derived artifacts on dev for v1.79.0-beta.1';
|
||||
assert.match(commit, /echo "docs: accept derived artifacts on dev for \$TAG"/);
|
||||
assert.equal(isCandidateSubject(subject), false, 'бандл кандидата у коммита бота не сверяется');
|
||||
const golden = [subject, '', 'Производные артефакты беты.', '',
|
||||
'Release: v1.79.0-beta.1', 'Baseline-Reviewed: https://github.com/o/r/actions/runs/1', 'Issue: #697', 'User-Visible: no'].join('\n');
|
||||
assert.deepEqual(validateCommitMessage(golden, ['docs/images/screenshots.json', 'demo/golden/baselines/a.png']), []);
|
||||
const docsOnly = [subject, '', 'Производные артефакты беты.', '', 'Issue: #697', 'User-Visible: no'].join('\n');
|
||||
assert.deepEqual(validateCommitMessage(docsOnly, ['docs/images/screenshots.json']), []);
|
||||
assert.notDeepEqual(validateCommitMessage(docsOnly, ['demo/golden/baselines/a.png']), [], 'эталоны без провенанса — отказ');
|
||||
});
|
||||
+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', () => {
|
||||
@@ -236,3 +236,20 @@ test('#510 AC1 / #601 AC1: мутанты по диффу запрашивают
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: 'x\n\nRelease: v1.2.3' }), true);
|
||||
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true' }), true);
|
||||
});
|
||||
|
||||
test('#697: на ветке задачи трейлер Release: тяжёлый набор не включает, на dev — как прежде', () => {
|
||||
const acceptance = 'test: accept golden\n\nIssue: #687\nUser-Visible: no\nRelease: v1.78.0-beta.9\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1';
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), false);
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'refs/heads/issue/687-x' }), false);
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance, refName: 'dev' }), true, 'кандидат на dev');
|
||||
assert.equal(heavyGatesRequested({ eventName: 'push', headMessage: acceptance }), true, 'без ветки — прежнее правило');
|
||||
assert.equal(heavyGatesRequested({ eventName: 'workflow_dispatch', fullInput: 'true', refName: 'issue/687-x' }), true,
|
||||
'ci:full/ci:golden — dispatch full=true на ветке задачи');
|
||||
assert.equal(screenshotsGateMode({ eventName: 'push', headMessage: acceptance, refName: 'issue/687-x' }), 'warn');
|
||||
const run = (env) => execFileSync(process.execPath, ['scripts/classify-changes.mjs', '--heavy'], {
|
||||
encoding: 'utf8', env: { ...process.env, ...env },
|
||||
}).trim();
|
||||
assert.equal(run({ EVENT_NAME: 'push', HEAD_MESSAGE: acceptance, REF_NAME: 'issue/687-x' }), 'heavy=false\nmutants_requested=false');
|
||||
const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
assert.equal((workflow.match(/REF_NAME: \$\{\{ github\.ref_name \}\}/g) || []).length >= 2, true, 'оба вызова знают ветку');
|
||||
});
|
||||
|
||||
@@ -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: () => {},
|
||||
};
|
||||
}
|
||||
@@ -574,3 +575,103 @@ test('#657 r1 H1 на настоящем git: fast-forward несёт свежи
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// #696: треки show/ship сливаются по лёгкому Validate — dispatch без мутантов.
|
||||
test('#696: realOps with mutants=false dispatches a light Validate and accepts a light proof', async () => {
|
||||
const sha = 'c'.repeat(40);
|
||||
const tree = 'd'.repeat(40);
|
||||
const dispatches = [];
|
||||
const light = { databaseId: 5, attempt: 1, status: 'completed', conclusion: 'success', url: 'https://run/5', event: 'workflow_dispatch', headSha: sha };
|
||||
const exec = (cmd, args) => {
|
||||
if (cmd === 'gh' && args[0] === 'workflow' && args[1] === 'run') { dispatches.push(args.join(' ')); return { status: 0, stdout: '', stderr: '' }; }
|
||||
if (cmd === 'gh' && args[0] === 'run' && args[1] === 'list') return { status: 0, stdout: JSON.stringify([light]), stderr: '' };
|
||||
throw new Error(`unexpected ${cmd} ${args.join(' ')}`);
|
||||
};
|
||||
let clock = 0;
|
||||
const lightProof = (row) => {
|
||||
const context = mergeProofContext(row, sha, tree);
|
||||
const proof = buildCiProof({
|
||||
candidateSha: sha, candidateTree: tree, runId: row.databaseId, attempt: 1, event: row.event,
|
||||
needs: {
|
||||
preflight: { result: 'success' },
|
||||
changes: { result: 'success', outputs: { heavy: 'false', mutants_requested: 'false', frontend: 'true', backend: 'false', integration: 'false' } },
|
||||
reuse: { result: 'success', outputs: {} }, frontend: { result: 'success' }, changed_mutants: { result: 'skipped' },
|
||||
},
|
||||
});
|
||||
return { ...context, proof, jobs: context.jobs.filter((job) => !job.name.startsWith('Мутанты')) };
|
||||
};
|
||||
const opsLight = realOps({
|
||||
repo: 'x/y', token: 'none', exec, mutants: false,
|
||||
sleep: async (ms) => { clock += ms; }, now: () => clock,
|
||||
candidateTree: async () => tree, proofContext: async (row) => lightProof(row),
|
||||
});
|
||||
opsLight.dispatchValidate('issue/9-x');
|
||||
assert.match(dispatches.at(-1), /mutants=false/);
|
||||
assert.equal((await opsLight.waitValidate(sha, { event: 'workflow_dispatch' })).result, 'green');
|
||||
const opsFull = realOps({
|
||||
repo: 'x/y', token: 'none', exec,
|
||||
sleep: async (ms) => { clock += ms; }, now: () => clock,
|
||||
candidateTree: async () => tree, proofContext: async (row) => lightProof(row),
|
||||
});
|
||||
opsFull.dispatchValidate('issue/9-x');
|
||||
assert.match(dispatches.at(-1), /mutants=true/);
|
||||
assert.notEqual((await opsFull.waitValidate(sha, { event: 'workflow_dispatch' })).result, 'green',
|
||||
'track ask still refuses a proof without mutants');
|
||||
});
|
||||
|
||||
test('#696: трек show/ship — слияние ждёт push-прогон кандидата, без второго dispatch', 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, mutants: false });
|
||||
assert.equal(r.action, 'push');
|
||||
assert.ok(!ops.calls.some((c) => c[0] === 'dispatch'), 'лёгкий Validate уже запущен push кандидата');
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'validate'), [['validate', 'cand-mat-on-dev1', 'push']]);
|
||||
});
|
||||
|
||||
test('#696: push-прогона на кандидате нет — лёгкий dispatch и ожидание его', async () => {
|
||||
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['missing', 'green'] });
|
||||
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops, mutants: false });
|
||||
assert.equal(r.action, 'push');
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'dispatch' || c[0] === 'validate'), [
|
||||
['validate', 'cand-mat-on-dev1', 'push'], ['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('#696: трек ask по-прежнему диспатчит Validate с мутантами и push-прогон не ждёт', async () => {
|
||||
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
|
||||
await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
|
||||
assert.deepEqual(ops.calls.filter((c) => c[0] === 'dispatch' || c[0] === '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',
|
||||
|
||||
@@ -46,11 +46,11 @@ const KEY_RULES = {
|
||||
['1-основное-правило', 'D сильнее A'],
|
||||
['3-правила', 'Ровно одна метка статуса'],
|
||||
['3-правила', 'Статус меняется до действия, а не после'],
|
||||
['5-лёгкий-трек-метка-small--путь-по-умолчанию', 'обосновывается не выбор лёгкого трека, а отказ от него'],
|
||||
['51-короткий-трек-метка-trivial', 'ожидаемое поведение уже зафиксировано'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'Метка владельца главнее критериев'],
|
||||
['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,186 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
SHIP_SRC_LINE_LIMIT, hasTrackLabel, parseNameStatus, parseNumstat, resolveTrack, shipLimitViolations, trackFromLabels,
|
||||
} from '../scripts/process-track.mjs';
|
||||
import { trackFromLabels as packetTrack } from '../scripts/task-packet.mjs';
|
||||
|
||||
// #696: конвейер ревью решает цену захода по треку; трек и рамки ship —
|
||||
// механические, потому что по ним задача сливается без ревью модели.
|
||||
|
||||
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'process-track.mjs');
|
||||
|
||||
test('пакет задачи и конвейер читают трек одной функцией (#696)', () => {
|
||||
assert.equal(packetTrack, trackFromLabels);
|
||||
});
|
||||
|
||||
test('явная трековая метка главнее признака инфраструктуры (#696)', () => {
|
||||
const infra = ['scripts/x.mjs', '.github/workflows/y.yml'];
|
||||
assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: true, full: false, infrastructure: true });
|
||||
assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, full: false, infrastructure: false });
|
||||
assert.equal(resolveTrack({ labels: ['small'], files: ['src/a.ts'] }).track, 'show');
|
||||
});
|
||||
|
||||
test('без трековой метки инфраструктура — show, продуктовая задача — ask (#696)', () => {
|
||||
assert.equal(resolveTrack({ labels: ['infra'], files: ['scripts/x.mjs'] }).track, 'show');
|
||||
assert.equal(resolveTrack({ labels: ['bug'], files: ['scripts/x.mjs', 'src/a.ts'] }).track, 'ask');
|
||||
assert.equal(resolveTrack({ labels: [], files: [] }).track, 'ask', 'пустой дифф не доказывает инфраструктуру');
|
||||
assert.equal(hasTrackLabel(['bug', 'P2']), false);
|
||||
assert.equal(hasTrackLabel(['trivial']), true);
|
||||
});
|
||||
|
||||
test('мутанты по диффу — только ask и метка ci:mutants (#696)', () => {
|
||||
assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).mutants, false);
|
||||
assert.equal(resolveTrack({ labels: ['track:show', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true);
|
||||
assert.equal(resolveTrack({ labels: ['track:ship', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true);
|
||||
assert.equal(resolveTrack({ labels: [], files: ['src/a.ts'] }).mutants, true);
|
||||
});
|
||||
|
||||
test('рамки ship: строки src/** считаются вместе, граница включительна (#696)', () => {
|
||||
const at = (added, deleted, path = 'src/a.ts') => ({ added, deleted, path });
|
||||
assert.deepEqual(shipLimitViolations({ numstat: [at(20, 10)] }), []);
|
||||
assert.equal(SHIP_SRC_LINE_LIMIT, 30);
|
||||
const over = shipLimitViolations({ numstat: [at(20, 5), at(4, 2, 'src/b.ts')] });
|
||||
assert.equal(over.length, 1);
|
||||
assert.match(over[0], /31 строк/);
|
||||
assert.deepEqual(shipLimitViolations({ numstat: [at(500, 0, 'scripts/x.mjs'), at(200, 0, 'test/x.test.mjs')] }), [],
|
||||
'тесты и скрипты рамку src не расходуют');
|
||||
});
|
||||
|
||||
test('рамки ship: новые файлы, двоичные, i18n, конфиг и Python (#696)', () => {
|
||||
const v = shipLimitViolations({
|
||||
numstat: [
|
||||
{ added: null, deleted: null, path: 'src/icon.png' },
|
||||
{ added: 1, deleted: 0, path: 'src/i18n/ru.json' },
|
||||
{ added: 1, deleted: 0, path: 'src/types.ts' },
|
||||
{ added: 1, deleted: 0, path: 'src/config-editor.ts' },
|
||||
{ added: 1, deleted: 0, path: 'custom_components/houseplan/__init__.py' },
|
||||
{ added: 1, deleted: 0, path: 'custom_components/houseplan/translations/en.json' },
|
||||
],
|
||||
nameStatus: [{ status: 'A', path: 'src/new-thing.ts' }, { status: 'M', path: 'src/types.ts' }],
|
||||
});
|
||||
assert.equal(v.length, 5, v.join('\n'));
|
||||
assert.ok(v.some((s) => s.startsWith('двоичные') && s.includes('src/icon.png')));
|
||||
assert.ok(v.some((s) => s.startsWith('новые файлы') && s.includes('src/new-thing.ts')));
|
||||
assert.ok(v.some((s) => s.startsWith('ключи i18n') && s.includes('src/i18n/ru.json') && s.includes('translations/en.json')));
|
||||
assert.ok(v.some((s) => s.startsWith('поля конфига') && s.includes('src/types.ts') && s.includes('src/config-editor.ts')));
|
||||
assert.ok(v.some((s) => s.startsWith('Python')));
|
||||
assert.deepEqual(shipLimitViolations({ nameStatus: [{ status: 'A', path: 'test/new.test.mjs' }] }), [],
|
||||
'новый тест вне src рамки не нарушает');
|
||||
});
|
||||
|
||||
test('разбор numstat и name-status: двоичный файл и переименование (#696)', () => {
|
||||
assert.deepEqual(parseNumstat('3\t1\tsrc/a.ts\n-\t-\tsrc/i.png\n'), [
|
||||
{ added: 3, deleted: 1, path: 'src/a.ts' }, { added: null, deleted: null, path: 'src/i.png' },
|
||||
]);
|
||||
assert.deepEqual(parseNameStatus('M\tsrc/a.ts\nR100\tsrc/old.ts\tsrc/new.ts\n'), [
|
||||
{ status: 'M', path: 'src/a.ts' }, { status: 'R100', path: 'src/new.ts' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('CLI ship-limits и resolve читают реальный дифф (#696)', (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-track-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) => {
|
||||
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
|
||||
git('config', 'core.hooksPath', '/dev/null');
|
||||
mkdirSync(join(dir, 'src'));
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'a\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'base');
|
||||
const base = git('rev-parse', 'HEAD');
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'a\nb\n');
|
||||
git('commit', '-q', '-am', 'small');
|
||||
const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: '' } });
|
||||
let r = run('ship-limits', `--base=${base}`);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^ship=true$/m);
|
||||
r = run('resolve', '--labels=track:show', `--base=${base}`);
|
||||
assert.match(r.stdout, /^track=show$/m);
|
||||
assert.match(r.stdout, /^mutants=false$/m);
|
||||
writeFileSync(join(dir, 'src', 'b.ts'), 'x\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'new file');
|
||||
r = run('ship-limits', `--base=${base}`);
|
||||
assert.match(r.stdout, /^ship=false$/m);
|
||||
assert.match(r.stdout, /новые файлы в src\/\*\*: src\/b\.ts/);
|
||||
r = run('ship-limits');
|
||||
assert.notEqual(r.status, 0, 'без базы рамки не проверяются');
|
||||
});
|
||||
|
||||
// ---------- конвейер читает трек (#696) ----------
|
||||
|
||||
const WORKFLOW = join(dirname(fileURLToPath(import.meta.url)), '..', '.github', 'workflows', '_process.yml');
|
||||
|
||||
test('конвейер: трек снимается до ребейза, мутанты и ship идут из него (#696)', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
|
||||
const branch = at(' - name: Перейти на ветку задачи\n');
|
||||
const track = at(' - name: "Трек задачи и рамки ship (#696)"\n');
|
||||
const rebase = at(' - name: Привести ветку к dev\n');
|
||||
const gate = at(' - name: Validate на материале\n');
|
||||
assert.ok(branch < track && track < rebase && rebase < gate, 'трек — после выбора ветки и до ребейза');
|
||||
const trackStep = workflow.slice(track, rebase);
|
||||
assert.match(trackStep, /LABELS: \$\{\{ needs\.guard\.outputs\.labels \}\}/, 'метки — текущие, из guard');
|
||||
assert.match(trackStep, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev: ветка show/ship не ребейзится');
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" resolve --labels="\$LABELS" --base=origin\/dev/);
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" ship-limits --base=origin\/dev --head=HEAD/);
|
||||
assert.match(trackStep, /grep -qx 'ship=true'; then\n\s+ship=true/, 'ship — только в рамках');
|
||||
assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек');
|
||||
assert.match(workflow, /labels=\$\(printf '%s\\n' "\$current" \| paste -sd, -\)/, 'guard отдаёт текущие метки');
|
||||
const rebaseStep = workflow.slice(rebase, at(' - name: Зафиксировать SHA материала ревью\n'));
|
||||
assert.match(rebaseStep, /if \[ "\$TRACK" = "show" \] \|\| \[ "\$TRACK" = "ship" \]; then\n\s+if git merge-tree --write-tree origin\/dev HEAD/,
|
||||
'show/ship не ребейзятся при чистом слиянии');
|
||||
const gateStep = workflow.slice(gate, at(' - name: Validate идёт — раунд продолжит событие\n'));
|
||||
assert.match(gateStep, /--mutants="\$\{MUTANTS:-true\}"/, 'по умолчанию — с мутантами');
|
||||
assert.match(gateStep, /MUTANTS: \$\{\{ steps\.track\.outputs\.mutants \}\}/);
|
||||
});
|
||||
|
||||
test('конвейер: ship в рамках сливается без модели, с маркером для пакетного ревью (#696)', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:'));
|
||||
assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true' && needs\.prepare\.outputs\.ship != 'true'/);
|
||||
const integrate = workflow.slice(workflow.indexOf('\n integrate:'));
|
||||
for (const step of ['Получить результат модели', 'Опубликовать документ ревью', '"Материал раунда воспроизводим (#413)"']) {
|
||||
const i = integrate.indexOf(`- name: ${step}`);
|
||||
assert.ok(i > 0, step);
|
||||
assert.match(integrate.slice(i, i + 400), /needs\.prepare\.outputs\.ship != 'true'/, `${step} пропускается на ship`);
|
||||
}
|
||||
const decide = integrate.slice(integrate.indexOf('- name: Решение по вердикту'), integrate.indexOf('- name: dev ушёл вперёд'));
|
||||
assert.match(decide, /if \[ "\$SHIP" = "true" \] && \[ "\$REUSE" != "true" \]; then[\s\S]*verdict=green; high=0/);
|
||||
assert.match(decide, /<!-- hp:ship-merge material=\$MATERIAL -->/, 'маркер для ship-review.mjs');
|
||||
assert.doesNotMatch(decide.slice(decide.indexOf('SHIP" = "true'), decide.indexOf('elif [ "$REUSE"')), /Вердикт:/,
|
||||
'слияние без ревью не называет себя вердиктом ревью');
|
||||
const merge = integrate.slice(integrate.indexOf('- name: Слить ветку в dev'), integrate.indexOf('- name: Переставить метку'));
|
||||
assert.match(merge, /--mutants="\$\{MUTANTS:-true\}"/);
|
||||
const env = modelJob.slice(modelJob.indexOf('- name: Что ревьюеру нужно из окружения'), modelJob.indexOf('- name: Установить Claude Code'));
|
||||
assert.match(env, /if \[ "\$STAGE" = "spec" \]; then deps=false; browser=false; fi/, 'ревью ТЗ не ставит окружение');
|
||||
assert.match(env, /if: steps\.env_needs\.outputs\.deps == 'true'\n\s+run: npm ci/);
|
||||
assert.match(env, /if: steps\.env_needs\.outputs\.browser == 'true' && steps\.pw\.outputs\.cache-hit != 'true'/);
|
||||
});
|
||||
|
||||
test('#697: полный набор на ветке задачи — только по меткам ci:full и ci:golden', () => {
|
||||
assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).full, false);
|
||||
assert.equal(resolveTrack({ labels: ['track:show', 'ci:golden'], files: ['src/a.ts'] }).full, true);
|
||||
assert.equal(resolveTrack({ labels: ['track:ask', 'ci:full'], files: ['src/a.ts'] }).full, true);
|
||||
assert.equal(resolveTrack({ labels: ['ci:mutants'], files: ['src/a.ts'] }).full, false, 'мутанты полного набора не заказывают');
|
||||
});
|
||||
|
||||
test('#697: конвейер передаёт полный набор гейту материала', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const workflow = readFileSync(WORKFLOW, 'utf8');
|
||||
const gate = workflow.slice(workflow.indexOf(' - name: Validate на материале\n'), workflow.indexOf(' - name: Validate идёт — раунд продолжит событие\n'));
|
||||
assert.match(gate, /FULL: \$\{\{ steps\.track\.outputs\.full \}\}/);
|
||||
assert.match(gate, /--full="\$\{FULL:-false\}"/);
|
||||
assert.match(workflow, /full=\$\(printf '%s\\n' "\$out" \| sed -n 's\/\^full=\/\/p'\)/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -612,7 +612,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам
|
||||
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
|
||||
const material = at(' - name: Зафиксировать SHA материала ревью\n');
|
||||
const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n');
|
||||
const gate = at(' - name: Validate с мутантами на материале\n');
|
||||
const gate = at(' - name: Validate на материале\n');
|
||||
assert.ok(material < reuse && reuse < gate, 'gate читает steps.reuse.outputs — стоит после шага reuse (ревью ТЗ r1)');
|
||||
const back = at(' - name: Validate красный — вернуть автору без ревью\n');
|
||||
const modelJob = at('\n model_review:\n');
|
||||
@@ -857,7 +857,8 @@ test('#551: gates, модель и интеграция имеют незави
|
||||
}
|
||||
assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/);
|
||||
assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/);
|
||||
assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/,
|
||||
// #696: `ship` в рамках модель не вызывает — её исход не ждётся только там.
|
||||
assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$SHIP" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/,
|
||||
'интеграция не доверяет failed/cancelled/skipped модели');
|
||||
assert.match(integrate, /цикл ревью не израсходован/);
|
||||
assert.match(integrate, /Бюджеты стадий \(#551\)/, 'длительности публикуются раздельно');
|
||||
@@ -968,3 +969,15 @@ test('guard перечисляет docs/reviews деревом, а не contents
|
||||
// потолка нет.
|
||||
assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/);
|
||||
});
|
||||
|
||||
test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:'));
|
||||
assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename'/,
|
||||
'признак инфраструктуры — пути диффа ветки против dev');
|
||||
assert.match(guard, /files\.every\(\(f\) => classify\(f\) !== "A"\)/, 'механический признак §1: ни одного файла класса A');
|
||||
assert.match(guard, /files\.length < 300/, 'обрезанный ответ compare инфраструктуру не доказывает');
|
||||
assert.match(guard, /; then\n\s+limit=2\n\s+echo "инфраструктурная задача без трековой метки/);
|
||||
assert.match(guard, /! has track:ship && ! has track:show && ! has track:ask \\\n\s+&& ! has small && ! has trivial; then/,
|
||||
'явная метка трека главнее признака инфраструктуры');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код
|
||||
// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath,
|
||||
shipReviewProblems, specSection,
|
||||
} from '../scripts/ship-review.mjs';
|
||||
import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs';
|
||||
import { archivePlan } from '../scripts/reviews-archive.mjs';
|
||||
|
||||
const sha = (c) => c.repeat(40);
|
||||
const MARKER = `<!-- hp:ship-merge material=${sha('a')} -->`;
|
||||
|
||||
test('#696 документ — по тегу беты или стабильному, имя фиксировано', () => {
|
||||
assert.equal(shipReviewDocPath('v1.79.0-beta.1'), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.1.md');
|
||||
assert.equal(shipReviewDocPath('v1.79.0'), 'docs/reviews/SHIP-REVIEW-v1.79.0.md');
|
||||
for (const bad of ['1.79.0', 'v1.79', 'v1.79.0-rc.1', '', 'v1.79.0/../x']) {
|
||||
assert.throws(() => shipReviewDocPath(bad), /not a release tag/, bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('#696 ship-задача — по маркеру конвейера или метке track:ship', () => {
|
||||
assert.equal(isShipIssue({ labels: [], comments: [{ body: `Слияние без ревью модели\n\n${MARKER}` }] }), true);
|
||||
assert.equal(isShipIssue({ labels: [{ name: 'track:ship' }], comments: [] }), true);
|
||||
assert.equal(isShipIssue({ labels: ['track:show'], comments: [{ body: 'hp:ship-merge без маркера' }] }), false);
|
||||
assert.equal(isShipIssue({}), false);
|
||||
});
|
||||
|
||||
test('#696 состав — трейлеры диапазона; show и ask в пакет не входят', () => {
|
||||
const commits = [
|
||||
{ sha: sha('c'), message: 'fix: text\n\nIssue: #701\nUser-Visible: yes' },
|
||||
{ sha: sha('b'), message: 'feat: y\n\nIssue: #702\nUser-Visible: yes' },
|
||||
{ sha: sha('a'), message: 'fix: css\n\nIssue: #701\nUser-Visible: yes' },
|
||||
{ sha: sha('d'), message: 'chore: упоминание #703 — не трейлер' },
|
||||
];
|
||||
const data = {
|
||||
701: { title: 'Опечатка', body: 'Отчёт\n\n## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.\n\n## Прочее\nх', labels: [], comments: [{ body: MARKER }] },
|
||||
702: { title: 'Фича', body: '', labels: [{ name: 'track:show' }], comments: [] },
|
||||
};
|
||||
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] ?? null });
|
||||
assert.deepEqual(ship.map((i) => i.number), [701]);
|
||||
assert.deepEqual(ship[0].commits.map((c) => c.sha), [sha('a'), sha('c')], 'коммиты по порядку истории');
|
||||
assert.equal(ship[0].spec, '## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.');
|
||||
const brief = renderShipBrief({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: { tag: 'v1.78.0', sha: sha('f') }, ship });
|
||||
assert.match(brief, /### #701 · Опечатка/);
|
||||
assert.match(brief, /SHIP-REVIEW-v1\.79\.0-beta\.1\.md/);
|
||||
assert.match(brief, new RegExp(sha('a')));
|
||||
assert.equal(specSection('без раздела'), '');
|
||||
});
|
||||
|
||||
test('#696 гейт: без ship-задач документ не нужен, без документа — отказ с командой', () => {
|
||||
assert.deepEqual(shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [], docText: null }), []);
|
||||
const [problem] = shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [{ number: 701 }], docText: null });
|
||||
assert.match(problem, /#701/);
|
||||
assert.match(problem, /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.1/);
|
||||
});
|
||||
|
||||
test('#696 гейт: машинный блок покрывает все задачи и не несёт High', () => {
|
||||
const tag = 'v1.79.0-beta.1';
|
||||
const doc = (fields) => `# Ревью\nИтог: …\n\n${anchorBlock({ tag, candidate: sha('e'), issues: [701, 704], ...fields })}`;
|
||||
assert.deepEqual(parseAnchorBlock(doc({ high: 0, medium: 1, low: 2 })), {
|
||||
tag, candidate: sha('e'), issues: [701, 704], high: 0, medium: 1, low: 2,
|
||||
});
|
||||
assert.deepEqual(shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 704 }], docText: doc({ high: 0 }) }), []);
|
||||
const partial = shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 709 }], docText: doc({ high: 0 }) });
|
||||
assert.equal(partial.length, 1);
|
||||
assert.match(partial[0], /не покрывает ship-задачи #709/);
|
||||
const high = shipReviewProblems({ tag, ship: [{ number: 701 }], docText: doc({ high: 2 }) });
|
||||
assert.equal(high.length, 1);
|
||||
assert.match(high[0], /High 2/);
|
||||
assert.match(shipReviewProblems({ tag, ship: [{ number: 701 }], docText: '# без блока' })[0], /без машинного блока/);
|
||||
assert.match(shipReviewProblems({ tag: 'v1.79.0-beta.2', ship: [{ number: 701 }], docText: doc({ high: 0 }) })[0], /для тега v1\.79\.0-beta\.1/);
|
||||
});
|
||||
|
||||
test('#696 индекс и архив знают SHIP-REVIEW: бета в индексе, архив — каталог стабильной линии', () => {
|
||||
assert.deepEqual(parseDocName('SHIP-REVIEW-v1.79.0-beta.1.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1' });
|
||||
const md = renderIndex({ entries: [
|
||||
{ name: 'SHIP-REVIEW-v1.79.0-beta.1.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.1', verdict: '—', high: 0, medium: 1, findings: [], files: [] },
|
||||
{ name: 'SHIP-REVIEW-v1.79.0-beta.2.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.2', verdict: '—', high: 0, medium: 0, findings: [], files: [] },
|
||||
{ name: 'RELEASE-REVIEW-v1.79.0.md', stage: 'release', issue: null, tag: 'v1.79.0', verdict: '—', high: 1, medium: 0, findings: [], files: [] },
|
||||
] });
|
||||
const rows = md.split('\n').filter((line) => line.startsWith('| бета') || line.startsWith('| линия'));
|
||||
assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['линия v1.79.0', 'бета v1.79.0-beta.2', 'бета v1.79.0-beta.1']);
|
||||
assert.match(rows[1], /пакетное ревью ship · —/);
|
||||
const plan = archivePlan({
|
||||
names: ['SHIP-REVIEW-v1.79.0-beta.1.md', 'SHIP-REVIEW-v1.80.0-beta.1.md'],
|
||||
lines: [{ tag: 'v1.79.0', issues: [] }], open: [], through: 'v1.79.0',
|
||||
});
|
||||
assert.deepEqual(plan.moves.map((m) => m.to), ['legacy/reviews/v1.79.0/SHIP-REVIEW-v1.79.0-beta.1.md']);
|
||||
assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md'));
|
||||
});
|
||||
|
||||
test('#696 ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => {
|
||||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/ship-review.yml', import.meta.url)), 'utf8');
|
||||
const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:'));
|
||||
assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает');
|
||||
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)');
|
||||
const publish = workflow.slice(workflow.indexOf('\n publish:'));
|
||||
assert.match(publish, /m\.anchorBlock\(/, 'машинный блок пишет публикация, не модель');
|
||||
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/);
|
||||
assert.match(workflow, /if: needs\.prepare\.outputs\.proceed == 'true'/);
|
||||
assert.match(workflow, /ship-задач в диапазоне нет — ревью не нужно/);
|
||||
});
|
||||
|
||||
test('#696 оба пути публикации беты проверяют пакетное ревью ship до выпуска', () => {
|
||||
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url)), 'utf8');
|
||||
const gate = workflow.slice(workflow.indexOf('\n gate:'), workflow.indexOf('\n publish:'));
|
||||
assert.match(gate, /node scripts\/ship-review\.mjs check --tag="\$TAG" --candidate="\$SHA"/, 'гейт CI-публикации');
|
||||
const local = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8');
|
||||
const main = local.slice(local.indexOf('const main = async'));
|
||||
const check = main.indexOf("'scripts/ship-review.mjs', 'check'");
|
||||
assert.ok(check > 0, 'локальная публикация зовёт тот же гейт');
|
||||
assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor,
|
||||
trackFromLabels, hasTrackLabel,
|
||||
} from '../scripts/task-packet.mjs';
|
||||
import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs';
|
||||
|
||||
@@ -87,7 +88,7 @@ test('пакет собирается и рендерится: статус, м
|
||||
validate: { status: 'зелёный', url: 'https://run' },
|
||||
});
|
||||
assert.equal(packet.status, 'S6-in-progress');
|
||||
assert.equal(packet.track, 'small');
|
||||
assert.equal(packet.track, 'show');
|
||||
assert.equal(packet.material.treeMatchesVerdict, true);
|
||||
assert.deepEqual(packet.unverified, ['AC2']);
|
||||
const md = renderPacket(packet);
|
||||
@@ -106,7 +107,7 @@ test('#562: statusless infra issue is the accelerated track ending at S7 review'
|
||||
branch: { name: 'issue/562-process', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
});
|
||||
assert.equal(packet.status, null);
|
||||
assert.equal(packet.track, 'инфраструктурный');
|
||||
assert.equal(packet.track, 'инфраструктурный · show', 'r1 #695: инфраструктура без трековой метки — show (§5.1)');
|
||||
const md = renderPacket(packet);
|
||||
assert.match(md, /инфраструктурный вход/);
|
||||
assert.match(md, /S7-code-review/);
|
||||
@@ -118,7 +119,7 @@ test('#562: the infra label alone never grants the accelerated track', () => {
|
||||
labels: ['infra', 'S6-in-progress'],
|
||||
branch: { name: 'issue/999-product', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: false },
|
||||
});
|
||||
assert.equal(packet.track, 'полный');
|
||||
assert.equal(packet.track, 'ask');
|
||||
assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО')));
|
||||
});
|
||||
|
||||
@@ -152,7 +153,7 @@ test('#632: product S6 issue keeps class A rights while its diff has no class A
|
||||
branch: { name: 'issue/607-ha-dialog-close', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
reviewDocs: [{ name: 'SPEC-REVIEW-607-r1.md', text: 'Вердикт: зелёный' }],
|
||||
});
|
||||
assert.equal(packet.track, 'small');
|
||||
assert.equal(packet.track, 'show');
|
||||
assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО')));
|
||||
assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')));
|
||||
assert.match(renderPacket(packet), /Продуктовый поток: .*ТЗ/);
|
||||
@@ -176,7 +177,7 @@ test('#632: statusless or returned infra issue without spec keeps the class A ba
|
||||
reviewDocs: [{ name: 'CODE-REVIEW-632-r1.md', text: 'Вердикт: жёлтый' }],
|
||||
});
|
||||
assert.deepEqual(packet.productFlow, [], labels.join(','));
|
||||
assert.equal(packet.track, 'инфраструктурный', labels.join(','));
|
||||
assert.equal(packet.track, 'инфраструктурный · show', labels.join(','));
|
||||
assert.ok(packet.rights.some((l) => l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
|
||||
assert.ok(packet.rights.every((l) => !l.includes('продуктовый код трогать МОЖНО')), labels.join(','));
|
||||
}
|
||||
@@ -192,7 +193,7 @@ test('#632 r1: trivial issue in S6/S7 keeps class A rights without any spec arte
|
||||
labels,
|
||||
branch: { name: 'issue/612-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
});
|
||||
assert.equal(packet.track, 'trivial', labels.join(','));
|
||||
assert.equal(packet.track, 'show', labels.join(','));
|
||||
assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО')), labels.join(','));
|
||||
assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
|
||||
}
|
||||
@@ -217,3 +218,26 @@ test('#517 AC5: AC берутся из тела issue, файл ТЗ — тол
|
||||
const neither = buildPacket({ ...base, issue: { ...base.issue, body: 'ничего' }, specs: [] });
|
||||
assert.deepEqual(neither.acceptance, []);
|
||||
});
|
||||
|
||||
test('#695: трек по меткам — track:* главнее прежних, по умолчанию ask', () => {
|
||||
assert.equal(trackFromLabels(['track:ship']), 'ship');
|
||||
assert.equal(trackFromLabels(['track:show', 'bug']), 'show');
|
||||
assert.equal(trackFromLabels(['track:ask', 'small']), 'ask', 'явная метка владельца главнее прежней');
|
||||
assert.equal(trackFromLabels(['trivial']), 'show');
|
||||
assert.equal(trackFromLabels(['small']), 'show');
|
||||
assert.equal(trackFromLabels(['bug', 'P2']), 'ask');
|
||||
assert.equal(trackFromLabels([]), 'ask');
|
||||
});
|
||||
|
||||
test('r1 #695: инфраструктурная задача с явной меткой трека несёт её, без метки — show', () => {
|
||||
const at = (labels) => buildPacket({
|
||||
issue: { number: 7, title: 'infra', state: 'OPEN', url: 'u', body: '' },
|
||||
labels,
|
||||
branch: { name: 'issue/7-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
|
||||
}).track;
|
||||
assert.equal(at(['infra']), 'инфраструктурный · show');
|
||||
assert.equal(at(['infra', 'track:ask']), 'инфраструктурный · ask', 'метка владельца главнее');
|
||||
assert.equal(at(['infra', 'track:ship']), 'инфраструктурный · ship');
|
||||
assert.equal(hasTrackLabel(['bug', 'infra']), false);
|
||||
assert.equal(hasTrackLabel(['small']), true);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs';
|
||||
import { validateGate, isMutantRun, proofCandidate, provesMutants } from '../scripts/validate-gate.mjs';
|
||||
import { buildCiProof } from '../scripts/ci-proof.mjs';
|
||||
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
|
||||
|
||||
@@ -46,7 +46,7 @@ function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
|
||||
});
|
||||
return { proof, jobs: [...BASE_JOBS, ...selected], reuseRuns: new Map() };
|
||||
},
|
||||
dispatch: async (ref) => { dispatched.push(ref); },
|
||||
dispatch: async (ref, { mutants = true, full = false } = {}) => { dispatched.push(`${mutants ? ref : `${ref}:light`}${full ? ':full' : ''}`); },
|
||||
sleep: async (ms) => { clock += ms; },
|
||||
now: () => clock,
|
||||
},
|
||||
@@ -211,3 +211,48 @@ test('#636: с ожиданием (умолчание) поведение пре
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.ok(fake.ops.now() > 0, 'один poll прошёл');
|
||||
});
|
||||
|
||||
// #696: треки show/ship доказываются лёгким Validate — без мутантов по диффу.
|
||||
test('#696: without mutants a completed green push run on the material is proof, no dispatch', async () => {
|
||||
const fake = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, url: 'https://run/push' })]], jobsById: { 7: OTHER_JOBS } });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false });
|
||||
assert.equal(outcome.result, 'green');
|
||||
assert.equal(outcome.url, 'https://run/push');
|
||||
assert.deepEqual(fake.dispatched, []);
|
||||
});
|
||||
|
||||
test('#696: without mutants a red push run returns the task, a push still running is not waited for', async () => {
|
||||
const red = fakeOps({ snapshots: [[run({ event: 'push', databaseId: 7, conclusion: 'failure', url: 'https://run/red' })]], jobsById: { 7: OTHER_JOBS } });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: red.ops, mutants: false })).result, 'failed');
|
||||
// Идущий push раунд не разбудит (resume слушает dispatch) — гейт диспатчит
|
||||
// свой лёгкий прогон и ждёт уже его.
|
||||
const running = [run({ event: 'push', databaseId: 7, status: 'in_progress', conclusion: null })];
|
||||
const fake = fakeOps({ snapshots: [running, [...running, run({ databaseId: 8, status: 'in_progress', conclusion: null })]], jobsById: { 8: OTHER_JOBS } });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, wait: false, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'pending');
|
||||
assert.equal(outcome.runId, 8);
|
||||
assert.deepEqual(fake.dispatched, ['issue/1:light'], 'the light dispatch asks for no mutants');
|
||||
});
|
||||
|
||||
test('#696: a completed run beats a newer running dispatch only without mutants', async () => {
|
||||
const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' });
|
||||
const running = run({ databaseId: 9, status: 'in_progress', conclusion: null });
|
||||
const light = fakeOps({ snapshots: [[running, done]], jobsById: { 7: OTHER_JOBS } });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green');
|
||||
assert.equal(proofCandidate(run({ event: 'push', status: 'in_progress' }), { mutants: false }), false);
|
||||
assert.equal(proofCandidate(run({ event: 'push' }), { mutants: true }), false);
|
||||
});
|
||||
|
||||
test('#697: ci:full/ci:golden — лёгкий push-прогон не доказательство, гейт диспатчит full=true', async () => {
|
||||
const done = run({ event: 'push', databaseId: 7, url: 'https://run/push' });
|
||||
const dispatchedRun = run({ databaseId: 8, status: 'in_progress', conclusion: null });
|
||||
const fake = fakeOps({ snapshots: [[done], [done], [done, dispatchedRun]], jobsById: { 7: OTHER_JOBS } });
|
||||
const outcome = await validateGate({ ref: 'issue/1', sha: SHA, ops: fake.ops, mutants: false, full: true, wait: false, pollMs: 1000 });
|
||||
assert.equal(outcome.result, 'pending');
|
||||
assert.equal(outcome.runId, 8);
|
||||
assert.deepEqual(fake.dispatched, ['issue/1:light:full']);
|
||||
// без метки тот же push-прогон — доказательство, и dispatch не нужен
|
||||
const light = fakeOps({ snapshots: [[done]], jobsById: { 7: OTHER_JOBS } });
|
||||
assert.equal((await validateGate({ ref: 'issue/1', sha: SHA, ops: light.ops, mutants: false, wait: false })).result, 'green');
|
||||
assert.deepEqual(light.dispatched, []);
|
||||
});
|
||||
|
||||
@@ -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