Compare commits

...
Author SHA1 Message Date
claude[bot] fa570642df docs: review document for #697
Issue: #697
User-Visible: no
2026-09-28 20:59:36 +00:00
Claude 2a62ad5b95 process: derived artifacts are accepted on dev once per beta (#697)
The screenshot fingerprint and golden baselines stop being a tax on every
task branch:

- Task branches no longer commit docs/images/** or golden baselines. On a
  branch the screenshot freshness stays a preflight warning; the review
  prompt, REVIEWER.md and AUTHOR.md drop check-docs as a per-task gate.
- beta-derived.yml refreshes them on dev in one bot commit before the beta
  candidate: canonical docs capture + docs:accept --reviewed, golden from
  the golden-images artifact of a completed Validate on dev +
  golden:accept --reviewed. A changed frame or scene is accepted only when
  named in the inputs; undeclared differences refuse. Baseline commits carry
  Release: and Baseline-Reviewed:; the subject is not a candidate subject.
- classify-changes: the Release: trailer on an issue/* branch no longer
  switches on the heavy set. ci:full / ci:golden do: process-track emits
  full=true, the review gate dispatches Validate with full=true and does not
  accept a light proof.

Canon: PROCESS.md §3 п.13, §5.1, §8, §11.4; CONTRIBUTING.md.

Issue: #697
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 23:38:50 +03:00
claude[bot] c716bb0f63 docs(reviews): индекс после сдвига каталога (#696)
Issue: #696
User-Visible: no
2026-09-28 20:33:14 +00:00
claude[bot] ac75df7e9a docs: review document for #696
Issue: #696
User-Visible: no
2026-09-28 20:33:11 +00:00
Claude e1ae8f4ac7 process: the review pipeline prices each round by track (#696)
show/ship stop paying for diff mutants and for every move of dev:

- scripts/process-track.mjs resolves the track from the current labels and
  the diff (show for unlabelled infra, ask for unlabelled product work) and
  checks the mechanical ship limits; outside them the pipeline comments and
  relabels track:ship -> track:show in the same round.
- Validate on the review material is light on show/ship: a completed push
  run on the exact SHA is proof, a dispatch asks mutants=false. ask and the
  ci:mutants label keep the mutant dispatch.
- show/ship skip the pre-review rebase when git merge-tree with dev is
  clean; the candidate is rebased once at merge and still passes Validate
  before the push to dev. The light merge waits for the push run of the
  candidate and dispatches only when none appears.
- ship inside the limits merges after the light Validate without a model
  review; the issue gets a machine marker hp:ship-merge.
- ship-review.yml + scripts/ship-review.mjs read the code of all ship
  tasks of a beta range in one model session and publish
  docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a
  range with ship tasks the document does not cover or that carries a High.
- show reviews judge correctness and AC; the spec review installs neither
  npm ci nor Chromium, the show review installs Chromium only when the issue
  names a smoke.

Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and
AGENTS.md digests.

Issue: #696
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 23:09:46 +03:00
claude[bot] 9e4bfb4379 docs(reviews): индекс после сдвига каталога (#695)
Issue: #695
User-Visible: no
2026-09-28 20:07:15 +00:00
claude[bot] d8465b3a09 docs: review document for #695
Issue: #695
User-Visible: no
2026-09-28 20:07:11 +00:00
Claude f6c76b7351 process: infrastructure without a track label runs as show (#695 r1)
CODE-REVIEW-695-r1 Medium: PROCESS §5.1 says an infrastructure task (§1)
without a track label reads as track:show, but neither the pipeline guard
nor the task packet did that.

- _process.yml guard: with no track:* and no small/trivial label, the
  diff of the task branch against dev (compare API) with no class A file
  gives the show cycle limit 2. A truncated compare answer (300 files)
  proves nothing and keeps the limit 4.
- task-packet.mjs: an infrastructure packet names the track it runs on:
  «инфраструктурный · show» without a label, the owner's label otherwise.
- Mutants guard-infra-keeps-ask-limit and
  packet-infra-track-ignores-show-default.

Issue: #695
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 22:50:19 +03:00
claude[bot] 51854aea53 docs: review document for #695
Issue: #695
User-Visible: no
2026-09-28 19:36:14 +00:00
Claude 57ce10721f process: tracks ship/show/ask are set by the owner's label (#695)
The analysis of 85 closed tasks #600-#691 showed that the light track
cost as much as the full one (115 min and 12 events vs 102 and 13) and
that the owner had no label to choose the route. The owner accepted the
proposal on 2026-09-28.

- PROCESS §5 is the track table: track:ship (S1 -> S5, one line under
  "## ТЗ", <= 30 src lines, batch review before the beta), track:show
  (default, S2 -> S5, up to three AC, no spec review, 2 code cycles),
  track:ask (full route). The owner's label beats the criteria, which
  become a hint; any agent may raise a track, only the owner lowers it.
- §5.1: ci:full / ci:golden / ci:mutants order heavy checks on any track;
  small and trivial read as track:show, no label as track:ask, an
  infrastructure task as track:show.
- §2, §2.2, §2.4, §2.5, §4, §7.1, §7.2, §9, §11 follow; AUTHOR/REVIEWER
  digests and AGENTS.md follow with the digest test and its mutants.
- task-packet.mjs reports the track via trackFromLabels(); the pipeline
  reads track:show/track:ship for the cycle limit of 2 and lets an
  explicit track:ask win. Pipeline behaviour by track is #696.

Issue: #695
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 22:13:21 +03:00
35 changed files with 2981 additions and 238 deletions
+192 -25
View File
@@ -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 }}
@@ -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
+214
View File
@@ -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"
+8
View File
@@ -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 }}
+334
View File
@@ -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
+5 -1
View File
@@ -84,6 +84,8 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
# #697: на ветке задачи `Release:` строгий режим не включает.
REF_NAME: ${{ github.ref_name }}
run: |
# #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой
# `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)`
@@ -282,7 +284,8 @@ jobs:
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
# без единого исполненного тяжёлого гейта.
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
# `Release:` вне веток задач, #697), по `workflow_dispatch full=true`, на
# PR (#479). Логика и
# её тест — scripts/classify-changes.mjs.
- id: heavy
env:
@@ -290,6 +293,7 @@ jobs:
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
MUTANTS_INPUT: ${{ inputs.mutants }}
REF_NAME: ${{ github.ref_name }}
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
- id: base
if: github.event_name != 'pull_request'
+12 -6
View File
@@ -73,13 +73,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
+9 -6
View File
@@ -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:`
+203 -105
View File
@@ -3,7 +3,7 @@
> **Статус документа: канон** (редакция 2026-08-13, ролевое уточнение
> 2026-09-13). Решения владельца, на
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
> имена английские · лёгкий трек **включён** · автор и ревьюер — независимые
> имена английские · треки `ship`/`show`/`ask` задаёт метка владельца (#695) · автор и ревьюер — независимые
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
> входят в общий флоу сразу на `S7-code-review`.
>
@@ -93,8 +93,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
→ S6-in-progress → S7-code-review ⟲ → S8-merged → закрыт при выпуске беты
служебные: blocked (поверх статуса) rejected (закрыт)
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
⟲ — возврат на правки, не более 4 циклов (§4), на треке `show` 2
трек `show` (§5) идёт S2-analysis → S5-ready, минуя S3 и S4
трек `ship` (§5) идёт S1-new → S5-ready, минуя S2, S3 и S4
инфраструктурный трек (§1): без S → S7-code-review ⟲ S6-in-progress → S8-merged
```
@@ -125,9 +126,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
5. приоритет **P1/P2/P3**;
6. тип: баг / фича / техдолг;
7. затронутые поверхности (модули, диалоги, бэкенд, i18n);
8. трек — **по умолчанию `small`** (§5). Если задача идёт полным треком,
называется критерий §5, который она не проходит: «обычный трек» без
названного критерия обоснованием не является.
8. трек — метка `track:ship`, `track:show` или `track:ask` (§5), **по
умолчанию `track:show`**. Для `track:ask` называется критерий §5, которого
задача не проходит; `track:ship` предлагается, когда правка описывается
одним предложением. Метка владельца главнее предложения аналитика.
- **Оценки и приоритет ставятся метками сразу, согласие не запрашивается.**
Комментарий аналитики — уведомление, а не запрос: **молчание владельца —
согласие**, несогласие он выражает правкой меток или комментарием, и это не
@@ -139,7 +141,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
умолчанию и `blocked`. Вопрос, который можно отложить до ТЗ, не задаётся в
аналитике; вопрос, не блокирующий написание ТЗ, не задаётся вовсе — вместо
него в ТЗ пишется блок принятых предположений.
- **Выход:** `S3-spec` — переход выполняет сам аналитик, не дожидаясь ответа.
- **Выход:** на `track:ask` — `S3-spec`, на `track:show` — `S5-ready` после AC в
теле issue (§5); переход выполняет сам аналитик, не дожидаясь ответа.
Либо, при явном конфликте со `SCOPE.md`, — предложение отклонить с причиной:
это единственный случай, когда аналитика останавливается и ждёт владельца.
@@ -159,7 +162,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
- **Ревьюер ≠ автор.** Ревьюер получает issue и ТЗ, без устных пояснений автора.
Его задача — не согласиться, а найти, где ТЗ не выполнимо или не проверяемо.
- **Артефакт:** `docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`, вердикт
зелёный / жёлтый / красный. Лёгкий трек — комментарий в issue.
зелёный / жёлтый / красный. Ревью ТЗ проходит только трек `ask` (§5).
- **High-находки блокируют.** Medium **в скоупе задачи** чинится в текущем
issue: без High это жёлтый вердикт, автор правит ТЗ, фикс проходит повторный
цикл. Medium **вне скоупа** — отдельный issue: чужой скоуп в этой задаче не
@@ -174,7 +177,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
Не работа, а **очередь**: единственный статус, из которого можно трогать код.
Все пункты обязательны:
- ТЗ существует, ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте;
- ТЗ существует, на `track:ask` ревью ТЗ зелёное, ссылки issue ↔ ТЗ на месте.
На `ship` и `show` ТЗ — в объёме §5, а пункты ниже закрываются одним словом
«нет»: критерии трека их исключают, иначе это `track:ask`;
- **AC1…ACn** — пронумерованные проверяемые критерии приёмки; у каждого указано,
чем он доказывается: `unit` / `backend` / `smoke` / `golden` / «ревью кода»;
- перечислены затронутые файлы и модули;
@@ -468,7 +473,8 @@ dev, ни при публикации документа код-ревью: ин
WSL/ext4 с clean опубликованным SHA и машинно-проверяемым паспортом. Второй
путь убирает только первый ожидаемо красный CI-прогон; полный GitHub Validate
на точном SHA коммита с эталонами остаётся обязательным. Принятие ради
зелёного CI — нарушение процесса.
зелёного CI — нарушение процесса. Где принимаются эталоны — §8: на `dev`
одним коммитом на бету, в задаче — только с меткой `ci:golden` (#697).
14. **Issue закрывается после выпуска беты** с зелёным CI на точном SHA. Не
раньше, не «по факту наличия кода», не исполнителем.
15. **Закрытый issue не переоткрывается.** Новый дефект — новый issue со ссылкой.
@@ -516,90 +522,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:*`.
---
@@ -643,7 +649,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 +701,7 @@ issue #NN
Короткие и однообразные, чтобы читались и человеком, и машиной.
- **Аналитика:** `Оценка: ценность N/10 · сложность N/10 · P<1-3> · тип ·
поверхности: … · дубликаты: … · лёгкий трек: да/нет`
поверхности: … · дубликаты: … · трек: ship/show/ask (причина)`
- **Занятие:** `Взял: <роль> · сессия <id> · ветка issue/NN-slug`
- **Хендофф:** `Сделано: … · Файлы: … · Гейты: <команда → результат> ·
НЕ сделано: … · Риски: … · Следующий статус: … · Новые issue: #…`
@@ -729,8 +735,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 +755,25 @@ 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 при том же
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
человек.
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота
проверяет релиз-менеджер, коммит ручной приёмки делает человек.
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
@@ -776,13 +782,32 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
всегда попадает, и решать нечего. Цена пропуска измерена: скриншоты не
пересняли в #230 и #234, и `dev` стоял с красным job `docs`, пока это не нашли
при следующей задаче (#237). Пересъёмка — по двум абзацам выше, коммит
вместе с задачей.
**Производные артефакты — на `dev`, один коммит на бету** (#697, решение
владельца 2026-09-28). Отпечаток скриншотов документации считается по всему
`src/**`, поэтому любая правка фронтенда делает его устаревшим. Пока его
коммитила каждая задача, `docs/images/screenshots.json` правили 70 раз за 14 дней,
и две параллельные задачи конфликтовали на нём гарантированно. Golden
оплачивала следующая задача: сдвиг, влитый одной, всплывал у другой (#687 →
#685, #688 → #689). Теперь:
- **ветки задач не коммитят** `docs/images/**` и `demo/golden/baselines/**`. На
ветке свежесть скриншотов — предупреждение preflight, а golden не идёт;
- **перед кандидатом беты** `beta-derived.yml` одним коммитом бота обновляет на
`dev` отпечаток и кадры (съёмка тем же каноном, приёмка `docs:accept
--reviewed`) и эталоны golden из артефакта полного Validate на `dev`
(`golden:accept --reviewed`, `Release:` и `Baseline-Reviewed:` в коммите).
Изменившийся кадр или сцена принимается, только если назван во входах
workflow; необъявленная разница — отказ с перечнем. Коммит проверяет
релиз-менеджер;
- **задача, которая меняет визуал намеренно**, ставит `ci:golden`: конвейер
прогоняет полный набор на материале ревью, и сдвинутые кадры задача
принимает сама — по §3 п.13;
- **`Release:` на ветке задачи тяжёлый набор не включает** (`classify-changes.mjs`):
тяжёлое запускают `ci:full` и `ci:golden`, трейлер `Release:` на `dev` и ночной
прогон.
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
@@ -850,8 +875,10 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
| `blocked` | Ждём внешнего или владельца, **поверх** статусной метки |
| `rejected` | Отклонено, issue закрыт |
Модификаторы: `small` (лёгкий трек, сложность ≤3), `trivial` (короткий трек,
§5.1), `hotfix`, `process`, `review-4`; приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`.
Модификаторы: трек `track:ship`/`track:show`/`track:ask` (§5), тяжёлые проверки
`ci:full`/`ci:golden`/`ci:mutants` (§5.1), `hotfix`, `process`, `review-4`;
приоритет `P1`/`P2`/`P3`; тип `bug`/`feature`/`tech-debt`. Прежние `small` и
`trivial` читаются как `track:show` (§5.1).
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
ортогональны процессу.
@@ -1065,6 +1092,38 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
по расписанию он судит `main` собственным телом из `main`.
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и
диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1:
инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`.
| | `ship` | `show` | `ask` |
|---|---|---|---|
| Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | с мутантами по диффу, dispatch |
| Ребейз до ревью | нет, если `git merge-tree` с `dev` чистый | нет, если чистый | да (#257) |
| Ревью модели | нет — пакетное ревью перед бетой (§11.7) | корректность и AC; окружение — по нужде | полное |
| Проверка кандидата слияния | ждёт push-прогон кандидата; dispatch — только если его нет | как у `ship` | dispatch с мутантами |
Метка `ci:mutants` возвращает мутанты по диффу на любом треке. Рамки `ship`
(§5) проверяет тот же шаг; выход за них — комментарий в issue и замена
`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue
комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя
не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно
применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан.
В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью
кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там.
Ориентир — данные #695: `small` стоил медиану 115 минут и 12 событий, `trivial` —
41 минуту и 5 событий; `show` целится в уровень `trivial`.
**Ревью `show` судит корректность и AC.** Medium — дефект поведения, который
увидит пользователь, или невыполненный AC. Бухгалтерия — нет мутанта или записи
в реестре, нечувствительный тест на побочный вызов, формулировка в документе —
Low и цикла не открывает. Отсутствие мутантов по диффу на `show` не находка:
полный реестр гоняется ночью. Ревью `show` не ставит Chromium, если тело issue
не называет смоук или браузер. Ревью ТЗ не ставит ни `npm ci`, ни браузер: кода
оно не исполняет.
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
`workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие**
@@ -1138,7 +1197,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
никто не может выйти и о котором никто не узнает, для конвейера хуже громкой
ошибки.
**Ветка приводится к `dev` до ревью, а не после** (#257). Раньше ревью читало ветку
**Ветка приводится к `dev` до ревью, а не после** (#257) — на треке `ask`. `show`
и `ship` с чистым слиянием ребейзятся один раз, при слиянии (#696, выше). Раньше ревью читало ветку
как есть, а слияние делало ребейз — проверенный SHA и слитый SHA были разными
коммитами. Пока расхождение с `dev` текстовое, ребейз упирается в конфликт и это
видно; смысловое расхождение git склеивает молча, и в `dev` уезжает комбинация,
@@ -1173,7 +1233,9 @@ 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.
@@ -1230,9 +1292,10 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
## 11. Исключения
### 11.1 Лёгкий трек
### 11.1 Треки
См. §5 — это не исключение из правила №1, а более дешёвый путь по тем же статусам.
См. §5 — треки `ship` и `show` не исключения из правила №1, а более дешёвые пути
по тем же статусам.
### 11.2 Аварийный хотфикс (метка `hotfix`, решение владельца)
@@ -1275,7 +1338,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 +1361,10 @@ Golden, браузерные смоки, performance и полный HA-харн
запись в issue публична и релиз-менеджер видит, что именно было сделано перед
выпуском.
Это исключение из правила «код-ревью не пропускается никогда» (§5, §7.1) —
единственное, и относится только к окну между `S8-merged` и выпуском.
Это исключение из правила «каждое изменение проходит код-ревью» (§5, §7.1).
Второе такое место — трек `ship` (§5): его ревью не отменяется, а переносится
на пакетное ревью диапазона перед бетой. Здесь же исключение относится только к
окну между `S8-merged` и выпуском.
### 11.5 Независимое ревью линии перед стабильным релизом
@@ -1360,6 +1428,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. Запрещено
+41 -26
View File
@@ -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,13 +81,13 @@
- Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа ·
вариант по умолчанию. Пока ждём ответа, 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)).
@@ -132,8 +139,9 @@
- Минимальный набор по изменённым поверхностям: `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. Команды —
целевые смоки, `no-new-any`; по диффу — `model-invariants`,
`pytest tests_backend`, junction parity; `golden:verify` — только с меткой
`ci:golden`. Команды —
в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает
обязательную часть (`docs/TESTING.md`, «Локальный набор перед пушем»).
- Бандл в коммит задачи не идёт: сборка переписывает отслеживаемый `dist/`,
@@ -142,9 +150,10 @@
- Новый код не добавляет `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 +173,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` не ждать. После прогона ревью метка меняется
+35 -8
View File
@@ -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,12 +71,13 @@
## Объём гейтов
- Всегда: `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-гейты)).
@@ -122,13 +123,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` судит поверхности всей линии
+210
View File
@@ -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
+175
View File
@@ -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
+97
View File
@@ -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
+210
View File
@@ -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
+4 -1
View File
@@ -1,9 +1,12 @@
# Индекс ревью
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 173, issue: 80. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 176, issue: 82. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|---|---|---|---|---:|---:|---|---|
| #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` |
+7
View File
@@ -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 }),
});
+13 -1
View File
@@ -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({
+33 -11
View File
@@ -132,6 +132,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);
@@ -187,8 +188,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 +209,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 +235,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
@@ -280,10 +281,29 @@ 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 });
@@ -306,11 +326,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);
+175 -9
View File
@@ -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",
}],
},
{
@@ -10262,8 +10262,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 +10273,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`.',
}],
},
{
@@ -10613,7 +10613,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',
}],
},
@@ -12239,8 +12239,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",
}],
},
{
@@ -13334,6 +13334,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');
+143
View File
@@ -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);
}
}
+5
View File
@@ -503,6 +503,11 @@ 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,
+5 -3
View File
@@ -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; }
+18 -7
View File
@@ -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));
+235
View File
@@ -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
View File
@@ -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); архивный файл — источник только у задач до
+41 -12
View File
@@ -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`);
+62
View File
@@ -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']), [], 'эталоны без провенанса — отказ');
});
+17
View File
@@ -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, 'оба вызова знают ветку');
});
+68
View File
@@ -574,3 +574,71 @@ 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'],
]);
});
+2 -2
View File
@@ -46,8 +46,8 @@ 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`'],
+186
View File
@@ -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'\)/);
});
+15 -2
View File
@@ -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/,
'явная метка трека главнее признака инфраструктуры');
});
+117
View File
@@ -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 тоже');
});
+30 -6
View File
@@ -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);
});
+47 -2
View File
@@ -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, []);
});