diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index e011ae52..6c6086af 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -82,19 +82,14 @@ jobs: # очереди concurrency, пока владелец снял метку или поставил blocked — # снимок события об этом не знает, а исполнять отозванный запрос нельзя. current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name') + labels=$(printf '%s\n' "$current" | paste -sd, -) has() { printf '%s\n' "$current" | grep -qx -- "$1"; } - BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false + BLOCKED=false; EXHAUSTED=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 + # Пути диффа ветки против dev — для признака инфраструктуры (§1, §5.1); + # без ветки или при отказе compare API список пуст. + changed=$(mktemp) # Этап определяется первым: от него зависит, какие вердикты считать. stage=""; marker="" @@ -111,10 +106,6 @@ jobs: stage="" fi - # Лимит циклов: 4 на `track:ask`, 2 на `track:show`/`track:ship` (PROCESS.md §4, §5). - limit=4 - if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi - # Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227). # # `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен @@ -223,27 +214,28 @@ jobs: 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 + # Guard историю не клонирует, поэтому пути — из compare API. Отказ + # API — пустой список: инфраструктура не доказана (fail-closed). + if [ -n "$branch" ]; then + gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \ + || : > "$changed" fi fi + # Трек и лимит циклов (PROCESS.md §4, §5, §5.1; #707) решает + # process-track.mjs — та же функция, что у шага трека в prepare и у + # пакета задачи. Своей логики трека здесь нет: две копии правила уже + # расходились (#707 п.2). Ответ compare на 300 файлов и больше полного + # списка не доказывает — скрипт оставляет `ask`/4. Скрипт не + # отработал — лимит 4, как без доказанного трека: guard обязан + # продолжить работу, а не встать. + limit=4 + track_out=$(node scripts/process-track.mjs limit --labels="$labels" --files="$changed") \ + || echo "::warning::process-track.mjs limit не отработал — лимит по умолчанию 4" + printf '%s\n' "$track_out" + new_limit=$(printf '%s\n' "$track_out" | sed -n 's/^limit=//p') + case "$new_limit" in ''|*[!0-9]*) : ;; *) limit="$new_limit" ;; esac + # Отказ обязан быть виден в issue, а не только в логе прогона. # Ревьюшная метка обещает работу; если конвейер её не начал и промолчал, # задача стоит в этом статусе бесконечно и никто об этом не узнаёт. @@ -303,7 +295,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" + echo "labels=$labels" >> "$GITHUB_OUTPUT" prepare: name: "Ревью: материал и deterministic gates" @@ -343,6 +335,10 @@ jobs: mutants: ${{ steps.track.outputs.mutants }} full: ${{ steps.track.outputs.full }} ship: ${{ steps.track.outputs.ship }} + # #707: риск по изменённым участкам — заметка ревьюеру и строка риска + # для комментария слияния ship (пакетное ревью, §11.7). + risk_note: ${{ steps.track.outputs.risk_note }} + ship_risk: ${{ steps.track.outputs.ship_risk }} steps: - name: Начать измерение стадии id: clock @@ -415,12 +411,17 @@ jobs: # Трек решает цену захода (PROCESS.md §5, §10.4, #696): мутантов в # разработке нет ни на одном треке (#709); `ship` сливается без модели, - # если правка укладывается в механические рамки. Рамки судят дифф от - # merge-base с dev — работу задачи, а не то, что принёс dev, — поэтому шаг - # стоит до ребейза. Выход за рамки повышает трек до `show` здесь же: - # повысить трек вправе любой, понизить — только владелец (§5). Скрипт - # берётся из dev, как помощник ребейза: show/ship до ревью не ребейзятся, - # и ветка, отставшая от dev, его может ещё не нести. + # если правка укладывается в механические рамки и не несёт риска по + # изменённым участкам (#707) без подтверждения владельца. Рамки и риск + # судят дифф от merge-base с dev — работу задачи, а не то, что принёс + # dev, — поэтому шаг стоит до ребейза. Повышение ship → show — здесь же: + # повысить трек вправе любой, понизить — только владелец (§5). Всё решает + # ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только + # исполняет: пишет комментарий из файла скрипта и меняет метки по его + # флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`, + # `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт берётся из dev, как + # помощник ребейза: show/ship до ревью не ребейзятся, и ветка, отставшая от + # dev, его может ещё не нести. - name: "Трек задачи и рамки ship (#696)" id: track env: @@ -429,42 +430,28 @@ jobs: STAGE: ${{ needs.guard.outputs.stage }} LABELS: ${{ needs.guard.outputs.labels }} BRANCH: ${{ steps.branch.outputs.name }} + OWNER: ${{ github.repository_owner }} 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") + # Комментарии — для строки владельца «Трек: — решение владельца» + # (#707). Не прочитаны — пустой файл: происхождение не установлено. + comments="$RUNNER_TEMP/track-comments.json" + gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \ + || : > "$comments" + out=$(node "$tools/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \ + --branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \ + --out="$RUNNER_TEMP/track" \ + --run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}") 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 < track:show: $violations" - fi + if printf '%s\n' "$out" | grep -qx 'raise=true'; then + gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$RUNNER_TEMP/track/raise.md" + gh issue edit "$NUM" --repo "${{ github.repository }}" \ + --add-label track:show --remove-label track:ship + echo "track:ship -> track:show" 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 и @@ -1182,6 +1169,8 @@ jobs: ${{ needs.prepare.outputs.rebase_note }} + ${{ needs.prepare.outputs.risk_note }} + ${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }} Правила ревью в этом промпте не повторяются (#634): их канон — @@ -1712,6 +1701,8 @@ jobs: REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }} REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }} SHIP: ${{ needs.prepare.outputs.ship }} + # #707: строка риска подтверждённого владельцем ship — для пакетного ревью. + SHIP_RISK: ${{ needs.prepare.outputs.ship_risk }} MATERIAL: ${{ needs.prepare.outputs.material_sha }} VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} @@ -1729,6 +1720,8 @@ jobs: cat > /tmp/ship-merge.md <.md\`. + ${SHIP_RISK} + EOF gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md diff --git a/AGENTS.md b/AGENTS.md index 4be85a1f..3999e6e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,11 @@ committing. 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. +A label is a proposal until the owner confirms it with a comment line +`Трек: — решение владельца`; an agent never writes that line. +Several track labels at once read as the strictest. A risky changed hunk +(geometry, touch, migration, devices, perf, a new UX key) raises an unconfirmed +`ship` to `show` at `S7`; on `show` it is a question to the reviewer (#707). `small` and `trivial` read as `show`. An **infrastructure** task — not a single class A file — skips analysis and spec and enters at `S7-code-review` (`PROCESS.md` §1). Every change is code-reviewed; on `ship` the review moves to a @@ -147,9 +152,12 @@ fresh checkout. A worktree works only on the machine that created it — its ## Handoff and the verdict -Start a task from its packet: `node scripts/task-packet.mjs --issue NN` (status, -track, what the status permits, the branch against `dev`, the previous verdict -and the unwitnessed AC; it writes nothing). The local gate is +Start a task from its packet: `node scripts/task-packet.mjs --issue NN` (status; +track with its basis, cycle limit and rebase policy; what the status permits; +the branch against `dev` and the next step — no rebase a clean `show`/`ship` +merge does not need; risk by changed hunks and what it means on this track; the +required checks, each with its reason; changelog and visual evidence; the +previous verdict and the unwitnessed AC; it writes nothing). The local gate is `npm run gate:small` (`docs/TESTING.md` › Локальный набор перед пушем); run the smokes named in the AC before `S7-code-review`. **"Verified" without a named command and its result is not evidence.** Comment formats — claim, handoff, diff --git a/PROCESS.md b/PROCESS.md index 0ab5b864..ddd5f44d 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -567,10 +567,19 @@ patch-id кандидата слияния: вердикт к работе за - Аналитик предлагает трек в «Оценке» (§7.2) и ставит метку. По умолчанию — `track:show`. -- Владелец ставит или меняет любую из трёх меток в любой момент; его метка - окончательна. +- Владелец ставит или меняет любую из трёх меток в любой момент. Трек он + подтверждает строкой `Трек: — решение владельца` в начале + строки своего комментария (тире любое, регистр не важен, #707); окончательна + метка, подтверждённая строкой владельца. Метка без такой строки — предложение: + аналитика, агента или конвейера. Действует самая поздняя строка владельца и + только для текущего трека. Агент эту строку не пишет никогда: агенты и + конвейер действуют от учётной записи владельца, и по автору события их не + отличить. - Повысить трек (`ship` → `show` → `ask`) вправе любой агент, с причиной в комментарии. Понизить — только владелец. +- Несколько трековых меток сразу — дефект разметки: действует строжайшая + (`ask` > `show` > `ship`), пакет задачи и сводка конвейера печатают + предупреждение (#707). - Трек пересматривается, когда владелец снял усложнявший пункт (#688): аналитик предлагает понижение, решает владелец. @@ -590,6 +599,33 @@ patch-id кандидата слияния: вердикт к работе за проверяет и исполняет конвейер (§10.4, #696); код `ship` читает пакетное ревью диапазона перед бетой (§11.7). +**Проверка риска по изменённым участкам** (#707). Рамки считают строки и файлы, +но не видят, что именно тронуто: обработчик `pointerdown` в двенадцать строк +проходит их так же, как опечатка. Поэтому на `S7` конвейер судит и изменённые +строки файлов класса A — дифф `git diff --unified=0` от merge-base с `dev` +(`scripts/change-risk.mjs`). Правило «участок» — любая изменённая строка кода в +файле из списка, правило «токен» — токен в изменённой строке; пустые строки, +комментарии и чистое переименование риска не дают, удаление кода — даёт. +Классы: `geometry`, `touch`, `migration`, `devices`, `perf`, `ux` и `visual` +(области `render` — путь отрисовки плана — и `ui` — стили интерфейса). Таблица +путей и токенов — эвристика и живёт в скрипте. + +- `ship` с любым классом, кроме `visual`, без подтверждения владельца конвейер + повышает до `show` тем же путём, что при выходе за рамки: комментарий с + классами и доказательствами `путь:строка`. Подтверждённый владельцем `ship` + остаётся, риск уходит в пакетное ревью (§11.7). `visual` ship не повышает: + CSS-мелочь — законный ship. Выход за рамки повышает трек и при + подтверждённом `ship`, как прежде. +- На `show` риск — вопрос ревьюеру, маршрут не меняется: по каждому классу + ревьюер называет документ или AC, где поведение уже зафиксировано; не нашёл — + Medium «решать есть что — нужен `track:ask`» с названным критерием. `show`, + подтверждённый владельцем, ревьюер не повышает: вопрос уходит владельцу с + вариантом по умолчанию «повысить до `ask`». На `ask` ревьюер сверяет, что + каждый класс покрыт AC ТЗ. +- Автор видит те же классы и их следствие в пакете задачи + (`node scripts/task-packet.mjs --issue NN`) до `S7`: пакет и конвейер читают + одно правило. + **Подсказка аналитику.** `track:show` уместен, когда выполнено всё сразу: - сложность и риск ≤ 3; @@ -609,7 +645,9 @@ patch-id кандидата слияния: вердикт к работе за подтверждать ощущением — только ссылкой на уже зафиксированное поведение. Если по ходу выясняется, что решать есть что, трек повышается до `ask`: issue уходит в `S3-spec` и получает полное ТЗ в теле issue по §7.1. Это не провал, а ранняя -диагностика. +диагностика. Проверка риска по участкам (выше) даёт признаку механическую опору: +рискованный участок повышает `ship` и требует от ревью `show` ссылки на +зафиксированное поведение. **Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега @@ -630,6 +668,13 @@ patch-id кандидата слияния: вердикт к работе за Validate с `full=true` на материале ревью (#697). Метка `ci:mutants` снята (#709): мутанты в разработке не гоняются ни на каком треке. +**`ci:golden` ставится по сигналу, а не на глаз** (#707). Сигнал — визуальный +риск в пути отрисовки плана (класс `visual`, область `render`, §5): пакет задачи +рекомендует `ci:golden`, если сдвиг кадров намерен, а заметка ревьюеру без метки +просит либо её, либо запись в «чего не проверял». Правка стилей интерфейса +(`ui`), только комментариев или только тестов сигнала не даёт. Риск сам полного +набора не включает, `visual` тоже: `full=true` дают только метки. + **Прежние метки.** `trivial` и `small` читаются как `track:show`; продуктовая задача без трековой метки — как `track:ask`; инфраструктурная задача (§1) без трековой метки — как `track:show`. Новые задачи получают только `track:*`. @@ -1209,6 +1254,15 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` снимает `scripts/process-track.mjs` в стадии подготовки — по текущим меткам и диффу от merge-base с `dev`, до ребейза. Прежние метки читаются по §5.1: инфраструктурная задача без трековой метки — `show`, продуктовая — `ask`. +Шаг трека делает **один вызов** скрипта по меткам, диффу и комментариям issue +(#707): трек и его основание (строка владельца, §5), рамки `ship`, риск по +изменённым участкам, повышение `ship` → `show`, заметку риска в промпт ревью +(не длиннее 25 строк) и строку риска для комментария слияния `ship`. Bash шага +только исполняет: комментарий берёт из файла скрипта, метки меняет по его флагу. +Guard берёт трек и лимит циклов из того же скрипта (`process-track.mjs +limit`) и своей логики трека не держит; ответ compare API на 300 файлов и +больше инфраструктуру не доказывает — `ask`, лимит 4. Этап `spec`, отсутствие +ветки и инфраструктурный дифф дают пустой риск и прежнее поведение. | | `ship` | `show` | `ask` | |---|---|---|---| @@ -1219,10 +1273,14 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit` Мутантов по диффу нет ни на одном треке (#709, §2.7). Рамки `ship` (§5) проверяет тот же шаг; выход за них — комментарий в issue и замена -`track:ship` на `track:show` в этом же заходе. Слияние `ship` оставляет в issue -комментарий с машинным маркером `hp:ship-merge`. Это не вердикт ревью и так себя -не называет; по маркеру пакетное ревью находит задачи диапазона. Повторно -применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан. +`track:ship` на `track:show` в этом же заходе; так же — рискованный участок без +подтверждения владельца (§5, #707), одним комментарием с нарушениями рамок. +Слияние `ship` оставляет в issue комментарий с машинным маркером `hp:ship-merge`. +Это не вердикт ревью и так себя не называет; по маркеру пакетное ревью находит +задачи диапазона. Если `ship` слит с риском (подтверждён владельцем или риск +только `visual`), комментарий несёт строку «Риск по участкам …» и машинную +``; строка маркера `hp:ship-merge` не меняется. +Повторно применимый зелёный вердикт (#499) главнее `ship`: код уже прочитан. В `dev` по-прежнему уезжает только SHA с зелёным Validate. Без ребейза до ревью кандидат собирается один раз, при слиянии, и лёгкий Validate проходит там. @@ -1573,7 +1631,11 @@ action ревью пускает ровно этого бота (`allowed_bots`, `scripts/ship-review.mjs prepare`; ship-задач нет — модель не запускается; - **суждение** — по строке ТЗ каждой задачи и её коммитам: делает ли код заявленное и только его, не ломает ли соседнее, не вышла ли правка из ship по - смыслу. Правила ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»; + смыслу. Под задачей бриф печатает строку риска из её комментария + `hp:ship-merge` (`hp:ship-risk`, #707): рискованные участки ship, + подтверждённого владельцем, конвейер не повышал — их первым читает пакетное + ревью. Комментарии до #707 строки не несут — риск не записан. Правила + ревьюера — `docs/process/REVIEWER.md`, «Пакетное ревью ship»; - **выход** — `docs/reviews/SHIP-REVIEW-<тег>.md` в `dev` с машинным блоком: задачи и счёт High/Medium/Low. У модели нет прав записи, документ публикует детерминированный шаг. diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index 11244ec8..bfdac207 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -51,6 +51,15 @@ `track:show`. Метка владельца главнее критериев; повысить трек может любой агент с причиной в комментарии, понизить — только владелец ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Трек подтверждает только владелец — строкой `Трек: — решение + владельца` в начале строки комментария. Метка без такой строки — предложение. + Агент эту строку не пишет никогда. Несколько трековых меток — действует + строжайшая ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Рискованный участок (геометрия, touch, миграция, устройства, perf, новый + UX-ключ) в `ship` без подтверждения владельца конвейер повышает до `show` при + `S7`; на `show` ревьюер спросит, где поведение зафиксировано, — нет ссылки, + повысь трек до `ask` до `S7`. Классы и следствие печатает пакет задачи + ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). - `track:show`: `S2-analysis` → `S5-ready`, до трёх AC автор пишет в теле issue до перехода; ревью ТЗ нет, лимит код-ревью 2. Уместен, когда всё сразу: сложность и риск ≤ 3, одна поверхность, нет миграции, нового @@ -60,7 +69,8 @@ проверить». Рамки: дифф `src/**` до 30 строк, без новых файлов, i18n, полей конфига и Python ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). - Тяжёлые проверки на любом треке — метками `ci:full` и `ci:golden`; - прежние `small` и `trivial` читаются как + `ci:golden` ставится по сигналу — визуальный риск в пути отрисовки плана, — + а риск сам полного набора не включает; прежние `small` и `trivial` читаются как `track:show` ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)). ## ТЗ (`S3-spec`) @@ -150,6 +160,10 @@ `pytest tests_backend`, junction parity; `golden:verify` — только с меткой `ci:golden` ([§8](../../PROCESS.md#8-гейты); `docs/TESTING.md`, «Локальный набор перед пушем»). +- Обязательные проверки с основаниями печатает пакет задачи: `gate:small`, + смоки `smoke-select` с видом связи, invariants при `geometry`, pytest при + Python, parity при зеркалах junction limits, `ci:golden` — только при + визуальном риске в пути отрисовки плана ([§8](../../PROCESS.md#8-гейты)). - Мутанты не гоняются ни локально, ни в CI: мутант своей защиты пишется в реестр, якорь сверяет `mutation-gate --check`, поимку — ночь (#709, [§2.7](../../PROCESS.md#27-код-ревью)). diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index 63a2a69b..de4ad305 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -145,6 +145,15 @@ - Ветка `show` с чистым слиянием к `dev` до ревью не приводится: материал — ветка как есть, кандидат проверит Validate при слиянии ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). +- Промпт несёт риск по изменённым участкам: по каждому классу назови документ + или AC, где поведение уже зафиксировано; не нашёл — Medium «решать есть что — + нужен `track:ask`» с названным критерием. `show`, подтверждённый владельцем, + не повышать — вопрос владельцу, вариант по умолчанию «повысить до `ask`»; на + `ask` — сверить, что каждый класс покрыт AC ТЗ + ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Визуальный риск в пути отрисовки плана без `ci:golden`: если задача меняет + вид, нужен `ci:golden`; иначе — запись в «чего не проверял» + ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)). ## Пакетное ревью ship @@ -155,6 +164,9 @@ соседнее, не вышла ли правка из ship по смыслу ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой), [§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Строка «Риск по участкам» под задачей — рискованные участки ship, + подтверждённого владельцем: конвейер их не повышал, пакетное ревью читает их + первыми ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). - Документ `docs/reviews/SHIP-REVIEW-<тег>.md` публикует детерминированный шаг; High не пускает бету, Medium и Low решает владелец ([§11.7](../../PROCESS.md#117-пакетное-ревью-ship-перед-бетой)). diff --git a/scripts/change-risk.mjs b/scripts/change-risk.mjs new file mode 100644 index 00000000..c0d9b058 --- /dev/null +++ b/scripts/change-risk.mjs @@ -0,0 +1,220 @@ +// Риск по изменённым участкам (#707, PROCESS.md §5): одна чистая функция над +// `git diff --unified=0 ..`. +// +// Трек `ship`/`show` держится на «решать нечего», а рамки ship считают строки +// и файлы, но не видят, ЧТО тронуто: обработчик `pointerdown` в двенадцать +// строк проходил их так же, как опечатка. Здесь судится изменённая строка, а не +// файл: правило «участок» — любая изменённая строка кода в файле из списка, +// правило «токен» — токен в изменённой строке любого файла класса A (§1). +// Монолиты (`houseplan-card.ts`, `houseplan-editor-runtime.ts`) судятся только +// токенами: ни один шаблон участка их не задевает, и тест держит это +// («монолит участком не судится»). +// +// Таблица — эвристика (ТЗ #707 §10 п.2): пути и токены меняются свободно, +// каждая строка покрыта положительным и отрицательным случаем в +// `test/process-track.test.mjs`. Ложное срабатывание стоит одного ревью модели, +// пропуск — не хуже, чем без проверки. +import { classify } from './change-classes.mjs'; + +/** Классы в порядке печати. `visual` — единственный, что ship не повышает. */ +export const RISK_CLASSES = Object.freeze(['geometry', 'touch', 'migration', 'devices', 'perf', 'ux', 'visual']); +export const RAISING_CLASSES = Object.freeze(RISK_CLASSES.filter((cls) => cls !== 'visual')); +/** На класс печатается не больше стольких доказательств; классифицируются все ханки. */ +export const RISK_EVIDENCE_LIMIT = 5; + +const escape = (text) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); +/** `wall-*` → `src/wall-<что угодно>.ts` верхнего уровня `src/`. */ +const src = (glob) => ({ label: glob, re: new RegExp(`^src/${escape(glob).replace(/\*/g, '[^/]*')}\\.ts$`) }); +const py = (stem) => ({ label: `${stem}.py`, re: new RegExp(`^custom_components/houseplan/${stem}\\.py$`) }); +const path = (label, re) => ({ label, re }); + +const RENDER_DIR = path('src/render/**', /^src\/render\//); + +/** Правило «участок»: класс → файлы, любая изменённая строка кода которых даёт класс. */ +const AREAS = { + geometry: [ + ...['physical-geometry', 'space-geometry', 'wall-*', 'junction-limits', 'coincident-partitions', + 'coordinate-canonicalization', 'opening-*', 'partition-openings', 'open-spans', 'near-axis', 'align-grid', + 'grid-scale', 'room-fit', 'resize*', 'stairs*', 'radar-geometry', 'zigbee-topology-geometry', + 'device-marker-geometry', 'plan-geometry-preflight', 'plan-optimizer', 'zero-walls', 'iso-projection'].map(src), + ...['geometry_migration', 'coordinate_canonicalization', 'junction_limits', 'wall_segment_model', + 'radar_geometry', 'projection'].map(py), + ], + touch: ['pointer-modality', 'pointer-move-queue', 'touch-gesture-click-guard', 'live-interaction-runtime', + 'live-viewport', 'viewport-transition', 'room-gear-drag'].map(src), + migration: [ + ...['types', 'config-*', 'wall-tool-compat', 'config-adoption', 'config-store'].map(src), + ...['store', 'geometry_migration', 'import_export', 'validation'].map(py), + ], + devices: [ + ...['device-toggle', 'marker-toggle-entity', 'integration-provider', 'virtual-light-state', 'vacuum*', + 'device-hit-owner'].map(src), + ...['auth', 'http_api', 'websocket_api', 'virtual_lights', 'vacuum_routes'].map(py), + ], + perf: [RENDER_DIR, ...['render-*', 'houseplan-render-lifecycle', 'iso-scene-render', 'glow-*', 'day-cycle-render', + 'initial-load', 'boot-soft-layout'].map(src)], + 'visual:render': [RENDER_DIR, ...['iso-*', 'glow-*', 'day-cycle-render', 'space-render', 'stairs-view', + 'device-visual', 'device-face'].map(src), path('*.generated.ts', /^src\/(?:.*\/)?[^/]*\.generated\.ts$/)], + 'visual:ui': [ + path('src/styles/**', /^src\/styles\//), + path('src/styles.ts', /^src\/styles\.ts$/), + path('src/**/*styles*.ts', /^src\/(?:.*\/)?[^/]*styles[^/]*\.ts$/), + path('src/**/*-style.ts', /^src\/(?:.*\/)?[^/]*-style\.ts$/), + ], +}; + +/** + * Правило «токен»: класс → регулярное выражение по изменённой строке файла + * класса A (кроме JSON — у переводов своё правило `ux`: текст перевода со словом + * «thickness» не геометрия). `snapshot` не `snapTo*`: геометрия — только привязка. + */ +const TOKENS = { + geometry: /[\w$]*(?:thickness|canonicaliz|junction|snap(?:to|pt))[\w$]*/gi, + touch: /(?:pointer(?:down|up|move|cancel)|touch(?:start|end|move|cancel))(?![a-z])|PointerEvent|TouchEvent|(?:set|release)PointerCapture|pointerType|touch-action|dblclick|contextmenu/g, + migration: /[\w$]*migrat[\w$]*|STORAGE_VERSION|schema_version/gi, + devices: /callService|turn_on|turn_off|is_admin|require_admin/g, + perf: /requestAnimationFrame|(?:Resize|Intersection|Mutation)Observer|getBoundingClientRect|getComputedStyle|offset(?:Width|Height|Top|Left)|will-change|backdrop-filter|(? (p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p); + for (const raw of String(text).split('\n')) { + if (file && (oldLeft > 0 || newLeft > 0)) { + if (raw.startsWith('-') && oldLeft > 0) { + file.lines.push({ side: '-', line: oldLine, text: raw.slice(1) }); oldLine += 1; oldLeft -= 1; continue; + } + if (raw.startsWith('+') && newLeft > 0) { + file.lines.push({ side: '+', line: newLine, text: raw.slice(1) }); newLine += 1; newLeft -= 1; continue; + } + if (raw.startsWith(' ')) { oldLine += 1; newLine += 1; oldLeft -= 1; newLeft -= 1; continue; } + if (raw.startsWith('\\')) continue; + oldLeft = 0; newLeft = 0; + } + let m; + if ((m = /^diff --git (?:"?a\/)(.+?)"? (?:"?b\/)(.+?)"?$/.exec(raw))) { + file = { oldPath: m[1], newPath: m[2], lines: [] }; + files.push(file); + continue; + } + if (!file) continue; + if ((m = /^--- (.+)$/.exec(raw))) { file.oldPath = m[1] === '/dev/null' ? null : unquote(m[1]).replace(/^a\//, ''); continue; } + if ((m = /^\+\+\+ (.+)$/.exec(raw))) { file.newPath = m[1] === '/dev/null' ? null : unquote(m[1]).replace(/^b\//, ''); continue; } + if ((m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(raw))) { + oldLine = Number(m[1]); oldLeft = m[2] === undefined ? 1 : Number(m[2]); + newLine = Number(m[3]); newLeft = m[4] === undefined ? 1 : Number(m[4]); + } + } + return files; +} + +/** Риск диффа без единого класса: этап spec, ветки нет, инфраструктура. */ +export const emptyRisk = () => ({ classes: [], raising: [], visual: { render: false, ui: false }, evidence: {}, counts: {} }); + +/** + * Классы риска диффа с доказательствами `путь:строка · правило`. + * `raising` — классы, которые повышают ship (все, кроме `visual`); + * `visual.render` / `visual.ui` — область визуального риска; `evidence` — не + * больше `RISK_EVIDENCE_LIMIT` строк на класс, `counts` — сколько всего. + */ +export function classifyRisk(diffText = '') { + // класс → ключ строки → { path, line, side, rules } + const hits = new Map(); + const add = (cls, where, rule) => { + if (!hits.has(cls)) hits.set(cls, new Map()); + const key = `${where.side}${where.path}:${where.line}`; + const entry = hits.get(cls).get(key) || { ...where, rules: [] }; + if (!entry.rules.includes(rule)) entry.rules.push(rule); + hits.get(cls).set(key, entry); + }; + const areas = { render: false, ui: false }; + for (const file of parseUnifiedDiff(diffText)) { + const removedKeys = new Set(); + for (const row of file.lines) { + const p = row.side === '-' ? file.oldPath : file.newPath; + if (row.side !== '-' || !p || !I18N_FILE.some((re) => re.test(p))) continue; + const key = JSON_KEY.exec(row.text); + if (key) removedKeys.add(key[1]); + } + for (const row of file.lines) { + const p = row.side === '-' ? file.oldPath : file.newPath; + if (!p || classify(p) !== 'A' || isCommentOrBlank(row.text, p)) continue; + const where = { path: p, line: row.line, side: row.side }; + for (const [cls, rules] of Object.entries(AREAS)) { + const rule = rules.find((r) => r.re.test(p)); + if (rule) add(cls, where, `участок ${rule.label}`); + } + if (!p.endsWith('.json')) { + for (const [cls, re] of Object.entries(TOKENS)) { + re.lastIndex = 0; + const found = [...new Set([...row.text.matchAll(re)].map((m) => m[0]))]; + for (const token of found) add(cls, where, `токен ${token}`); + } + } + if (row.side === '+') { + if (I18N_FILE.some((re) => re.test(p))) { + const key = JSON_KEY.exec(row.text); + if (key && !removedKeys.has(key[1])) add('ux', where, `новый ключ "${key[1]}"`); + } + if (row.text.includes(DEFINE)) add('ux', where, `токен ${DEFINE}`); + } + } + } + const risk = emptyRisk(); + const merged = new Map(); + for (const [cls, entries] of hits) { + const name = cls.startsWith('visual:') ? 'visual' : cls; + if (cls === 'visual:render') areas.render = true; + if (cls === 'visual:ui') areas.ui = true; + if (!merged.has(name)) merged.set(name, new Map()); + for (const [key, entry] of entries) { + const into = merged.get(name).get(key) || { ...entry, rules: [] }; + const area = cls.startsWith('visual:') ? ` (${cls.slice('visual:'.length)})` : ''; + for (const rule of entry.rules) if (!into.rules.includes(`${rule}${area}`)) into.rules.push(`${rule}${area}`); + merged.get(name).set(key, into); + } + } + for (const cls of RISK_CLASSES) { + const entries = merged.get(cls); + if (!entries?.size) continue; + risk.classes.push(cls); + const list = [...entries.values()]; + risk.counts[cls] = list.length; + risk.evidence[cls] = list.slice(0, RISK_EVIDENCE_LIMIT).map((e) => `${e.path}:${e.line}${e.side === '-' ? ' (удалена)' : ''} · ${e.rules.join(', ')}`); + } + risk.raising = risk.classes.filter((cls) => RAISING_CLASSES.includes(cls)); + risk.visual = areas; + return risk; +} + +/** Строка класса для комментария, промпта и пакета: `touch: a:1 · …; b:2 · … и ещё N`. */ +export function riskClassLine(risk, cls) { + const evidence = risk?.evidence?.[cls] || []; + const more = (risk?.counts?.[cls] ?? evidence.length) - evidence.length; + const area = cls === 'visual' + ? ` (${[risk.visual?.render && 'render', risk.visual?.ui && 'ui'].filter(Boolean).join(', ')})` : ''; + return `${cls}${area}: ${evidence.join('; ')}${more > 0 ? `; и ещё ${more}` : ''}`; +} diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 97e4d043..d59c01f3 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13639,27 +13639,29 @@ const MUTANT_DEFINITIONS = [ replace: ' if True: # mutant: concurrent flushes are not serialised\n', }], }, - // r1 #695: инфраструктура без трековой метки — трек show (§5.1). + // r1 #695: инфраструктура без трековой метки — трек show (§5.1). С #707 трек и + // лимит guard берёт из process-track.mjs: якорь переехал туда, свидетель + // исполняет шаг guard настоящим bash. { 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', + + 'the guard must give it the show cycle limit 2, not the ask limit 4 (#707: one rule in process-track.mjs)', patches: [{ - file: '.github/workflows/_process.yml', - find: ' limit=2\n echo "инфраструктурная задача без трековой метки', - replace: ' limit=4 # mutant: infra keeps the ask limit\n echo "инфраструктурная задача без трековой метки', + file: 'scripts/process-track.mjs', + find: "(hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask'));", + replace: "(hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'ask' : 'ask')); // mutant: infra keeps the ask limit", }], }, { 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', + + 'without a label, the owner label otherwise (#707: via trackOrigin, shared with the pipeline)', 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', + find: ' const origin = trackOrigin({ labels, comments, owner, infrastructure: infrastructure || infrastructureHint });', + replace: ' const origin = trackOrigin({ labels, comments, owner, infrastructure: false }); // mutant: unlabelled infra reads as ask', }], }, // #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой. @@ -13696,14 +13698,15 @@ const MUTANT_DEFINITIONS = [ }], }, { + // #707: решение по ship на S7 переехало из bash шага трека в decideTrack. id: 'pipeline-ship-ignores-limits', - guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs', + guard: 'node --test --test-name-pattern="#707 AC3" test/process-track.test.mjs', because: '#696: ship skips the model only inside the limits; ignoring the limits merges ' - + 'unread code of any size', + + 'unread code of any size — even when the owner confirmed the ship track (#707)', 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', + file: 'scripts/process-track.mjs', + find: ' if (violations.length || riskRaises) {', + replace: ' if (riskRaises) { // mutant: ship limits ignored', }], }, { diff --git a/scripts/process-track.mjs b/scripts/process-track.mjs index 4190e0ad..0ec11ad4 100644 --- a/scripts/process-track.mjs +++ b/scripts/process-track.mjs @@ -1,42 +1,65 @@ #!/usr/bin/env node /** - * Трек задачи и рамки `ship` (PROCESS.md §5, #695/#696). + * Трек задачи, его основание, лимит циклов и рамки `ship` (PROCESS.md §5, + * #695/#696/#707) — одно правило на конвейер и пакет задачи. * + * node scripts/process-track.mjs stage --stage=code|spec --labels="a,b" --branch=<имя> \ + * --base=origin/dev --head=HEAD --comments= --owner= --out= [--run-url=] + * node scripts/process-track.mjs limit --labels="a,b" [--files=<список путей>] * node scripts/process-track.mjs resolve --labels="a,b" --base= --head= * node scripts/process-track.mjs ship-limits --base= --head= * - * `resolve` печатает `track=ship|show|ask`, `mutants=true|false` и - * `full=true|false` — то, что конвейер ревью читает, решая, сколько стоит - * заход: мутантов в разработке нет ни на одном треке (#709) — `mutants` - * всегда `false`, весь реестр проверяет только ночной прогон; полный - * набор (смоки, golden, perf) на ветке задачи — только меткам `ci:full` и - * `ci:golden` (#697). Инфраструктурная задача без трековой - * метки — `show` (§5.1); признак инфраструктуры механический, как в §1: в - * диффе ни одного файла класса A. + * `stage` — шаг «Трек задачи и рамки ship» конвейера: ОДИН вызов решает трек, + * его основание, рамки ship, риск по изменённым участкам (#707), повышение + * ship → show, заметку риска для промпта ревью и строку риска для комментария + * слияния ship. Печатает `track=`, `mutants=`, `full=`, `ship=`, `raise=`, + * `basis=`, `risk=`; те же поля и многострочные `risk_note`/`ship_risk` пишет в + * `$GITHUB_OUTPUT`; при `raise=true` кладёт тело комментария в `/raise.md`. + * Bash шага только исполняет: логики трека в нём нет. * - * `ship-limits` печатает `ship=true|false` и по строке `violation=…` на каждое - * нарушение рамок: дифф `src/**` не больше 30 строк, без новых файлов в - * `src/**`, без i18n, без полей конфига и без Python. Рамки механические - * намеренно: по ним конвейер сливает задачу без ревью модели, и решать их - * «на глаз» некому. + * `limit` — job `guard`: трек и лимит циклов по меткам и списку файлов из + * compare API; ответ на 300 файлов и больше инфраструктуру не доказывает. + * + * Мутантов в разработке нет ни на одном треке (#709) — `mutants` всегда + * `false`. Полный набор (смоки, golden, perf) на ветке задачи — только меткам + * `ci:full` и `ci:golden` (#697): риск, включая `visual`, его не заказывает. + * Рамки ship механические намеренно: по ним конвейер сливает задачу без ревью + * модели, и решать их «на глаз» некому. */ import { spawnSync } from 'node:child_process'; -import { appendFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { isMainModule } from './spawn-portable.mjs'; -import { classify } from './process-gate.mjs'; +import { classify } from './change-classes.mjs'; +import { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES } from './change-risk.mjs'; + +// Классификатор риска живёт рядом (ТЗ #707 §10 п.5); пакет и конвейер берут его отсюда. +export { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES }; export const SHIP_SRC_LINE_LIMIT = 30; +/** Потолок ответа compare API: полный список файлов при нём не доказан. */ +export const COMPARE_FILES_CAP = 300; +/** Не больше стольких строк заметки риска в промпте ревью. */ +export const RISK_NOTE_LINE_LIMIT = 25; + +/** Трековые метки от строжайшей к мягкой. */ +const STRICTEST_FIRST = ['ask', 'show', 'ship']; + +/** Явные трековые метки задачи, строжайшая первой. */ +export const explicitTracks = (labels = []) => STRICTEST_FIRST.filter((track) => labels.includes(`track:${track}`)); /** - * Трек по меткам: `track:*` главнее прежних меток; `trivial` и `small` - * читаются как `show` (§5.1); задача без трековой метки — `ask`. Метки трека - * не доказывают продуктовый поток: инфраструктурной задаче владелец тоже может + * Трек по меткам: `track:*` главнее прежних меток, из нескольких `track:*` + * действует строжайшая (`ask` > `show` > `ship`) — это дефект разметки, и + * решать его в пользу дешёвого трека нельзя (#707). `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'; + const [strictest] = explicitTracks(labels); + if (strictest) return strictest; if (labels.includes('trivial') || labels.includes('small')) return 'show'; return 'ask'; } @@ -46,21 +69,121 @@ export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'trac .some((label) => labels.includes(label)); /** - * Трек, по которому конвейер оценивает заход. Явная метка решает всё; без неё - * инфраструктурная задача (ни одного файла класса A в диффе) — `show`, прочие — - * `ask`. Мутанты проверяют тесты, а не продукт: в разработке их не гоняют ни - * локально, ни в CI (#709, решение владельца 2026-09-29) — только ночной полный - * реестр (`mutation-gate.yml`, #513). Поле остаётся для совместимости выхода. - * Полный набор — по меткам `ci:full` и `ci:golden` на любом треке (#697). + * Трек по меткам и признаку инфраструктуры: явная метка решает всё; без неё + * инфраструктурная задача (§1) — `show`, прочие — `ask` (§5.1). Одна функция на + * guard, шаг трека и пакет задачи. */ -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'); +export const labelTrack = ({ labels = [], infrastructure = false } = {}) => (hasTrackLabel(labels) ? trackFromLabels(labels) : (infrastructure ? 'show' : 'ask')); + +/** + * Трек, по которому конвейер оценивает заход. Признак инфраструктуры + * механический, как в §1: в диффе ни одного файла класса A; обрезанный список + * (`filesCapped`) его не доказывает. Мутанты проверяют тесты, а не продукт: в + * разработке их не гоняют ни локально, ни в CI (#709, решение владельца + * 2026-09-29) — только ночной полный реестр (`mutation-gate.yml`, #513). Поле + * остаётся для совместимости выхода. Полный набор — по меткам `ci:full` и + * `ci:golden` на любом треке (#697). + */ +export function resolveTrack({ labels = [], files = [], filesCapped = false } = {}) { + const infrastructure = !filesCapped && files.length > 0 && files.every((file) => classify(file) !== 'A'); + const track = labelTrack({ labels, infrastructure }); const mutants = false; const full = labels.includes('ci:full') || labels.includes('ci:golden'); return { track, mutants, full, infrastructure }; } +/** Лимит циклов код-ревью (§4, §5): `ask` — 4, `show`/`ship` — 2. */ +export const cycleLimit = (track) => (track === 'ask' ? 4 : 2); + +/** + * Трек и лимит для job `guard`: пути — из compare API, у которого потолок + * ответа 300 файлов; такой ответ инфраструктуру не доказывает — остаётся `ask`/4. + */ +export function guardLimit({ labels = [], files = [] } = {}) { + const { track, infrastructure } = resolveTrack({ labels, files, filesCapped: files.length >= COMPARE_FILES_CAP }); + return { track, limit: cycleLimit(track), infrastructure }; +} + +/** + * Ребейз до ревью (§10.4, #257, #696): `ask` приводится к `dev` всегда; + * `show`/`ship` — только если слияние с `dev` не чистое (`mergeClean !== true`: + * неизвестная чистота — тоже ребейз, как у шага «Привести ветку к dev»). + */ +export const rebaseBeforeReview = (track, mergeClean) => track === 'ask' || mergeClean !== true; + +/** + * Строка подтверждения трека владельцем (§5, #707): в начале строки + * комментария, тире любое из «—», «–», «-», слова без учёта регистра. Агент её не + * пишет никогда: агенты и конвейер действуют от учётной записи владельца, и по + * автору события их не отличить (ТЗ #707 §10 п.1). + */ +export const OWNER_TRACK_LINE = /^[ \t]*трек:[ \t]*(ship|show|ask)[ \t]*[—–-][ \t]*решение владельца/gimu; + +/** Комментарии из `gh issue view --json comments` (или массива) в общий вид. */ +export function normalizeComments(data) { + const list = Array.isArray(data) ? data : data?.comments; + if (!Array.isArray(list)) return null; + return list.map((c) => ({ + author: typeof c?.author === 'string' ? c.author : (c?.author?.login ?? null), + body: String(c?.body ?? ''), + createdAt: c?.createdAt ?? null, + url: c?.url ?? null, + })); +} + +/** Самая поздняя строка владельца `Трек: — решение владельца` по времени комментария. */ +export function ownerTrackLine(comments, owner) { + if (!Array.isArray(comments) || !owner) return null; + const who = String(owner).toLowerCase(); + const sorted = comments.map((c, i) => ({ c, i })).sort((a, b) => { + const x = String(a.c?.createdAt ?? ''); const y = String(b.c?.createdAt ?? ''); + return x < y ? -1 : x > y ? 1 : a.i - b.i; + }); + let latest = null; + for (const { c } of sorted) { + const author = typeof c?.author === 'string' ? c.author : c?.author?.login; + if (String(author ?? '').toLowerCase() !== who) continue; + for (const m of String(c?.body ?? '').matchAll(OWNER_TRACK_LINE)) { + latest = { track: m[1].toLowerCase(), at: c.createdAt ?? null, url: c.url ?? null }; + } + } + return latest; +} + +/** + * Трек и его основание (К2 #707). Метка без строки владельца — предложение + * (аналитика, агента, конвейера); подтверждает самая поздняя строка владельца, + * и только если её трек равен текущему. Комментарии недоступны (`null`) — + * «происхождение не установлено», подтверждения нет. Несколько трековых меток — + * строжайшая и предупреждение в основании. + */ +export function trackOrigin({ labels = [], comments = [], owner = '', infrastructure = false } = {}) { + const explicit = explicitTracks(labels); + const track = labelTrack({ labels, infrastructure }); + const warning = explicit.length > 1 + ? `несколько трековых меток (${explicit.map((t) => `track:${t}`).join(', ')}) — дефект разметки, действует строжайшая track:${track}` + : null; + let origin; let basis; let confirmed = false; let confirmedAt = null; + if (explicit.length) { + if (!Array.isArray(comments) || !owner) { + origin = 'unknown'; basis = 'метка, происхождение не установлено (комментарии недоступны)'; + } else { + const line = ownerTrackLine(comments, owner); + if (line && line.track === track) { + origin = 'owner'; confirmed = true; confirmedAt = line.at; + basis = `метка, подтверждённая владельцем (${String(line.at ?? '').slice(0, 10) || 'дата не записана'})`; + } else { + origin = 'proposal'; basis = 'метка без подтверждения — предложение'; + } + } + } else if (labels.includes('small') || labels.includes('trivial')) { + origin = 'legacy'; basis = `прежняя метка ${labels.includes('small') ? 'small' : 'trivial'} → show (§5.1)`; + } else { + origin = 'default'; basis = infrastructure ? 'метки нет: инфраструктура → show' : 'метки нет: продукт → ask'; + } + return { track, basis, origin, confirmed, confirmedAt, warning, explicit }; +} + const I18N = [/^src\/i18n\//, /^custom_components\/[^/]+\/translations\//]; const CONFIG = [/^src\/types\.ts$/, /^src\/config-[^/]+\.ts$/]; const PYTHON = /\.py$/; @@ -107,15 +230,117 @@ export function parseNameStatus(text = '') { }); } +/** + * Заметка риска для промпта ревью `show`/`ask` (К3 #707), не длиннее + * `RISK_NOTE_LINE_LIMIT` строк; пустой риск — пустая строка. Риск — вопрос + * ревьюеру, маршрут не меняется: автоматического show → ask нет. + */ +export function riskNote({ track, confirmed = false, risk, labels = [] } = {}) { + if (!risk?.classes?.length) return ''; + const lines = []; + const raising = risk.raising || []; + if (raising.length && (track === 'show' || track === 'ask')) { + if (track === 'show' && !confirmed) { + lines.push('**Риск по изменённым участкам (#707).** Трек show держится на «решать нечего» (PROCESS.md §5). По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; не нашёл — Medium «решать есть что — нужен track:ask» с названным критерием §5.'); + } else if (track === 'show') { + lines.push('**Риск по изменённым участкам (#707).** Трек show подтверждён владельцем. По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; трек не повышать: не нашёл — вопрос владельцу в комментарии, вариант по умолчанию «повысить до ask».'); + } else { + lines.push('**Риск по изменённым участкам (#707).** Трек ask: сверь, что каждый класс ниже покрыт AC ТЗ.'); + } + for (const cls of raising) lines.push(`- ${riskClassLine(risk, cls)}`); + } + if (risk.visual?.render && !labels.includes('ci:golden')) { + lines.push('Визуальный риск в пути отрисовки плана без ci:golden — если задача меняет вид, нужен ci:golden (PROCESS.md §8); иначе запиши в «чего не проверял».'); + lines.push(`- ${riskClassLine(risk, 'visual')}`); + } + return lines.slice(0, RISK_NOTE_LINE_LIMIT).join('\n'); +} + +/** + * Строка риска для комментария слияния ship (`hp:ship-merge`): человеку и + * машинная `` для пакетного ревью (§11.7). + * Без риска — пустая строка. + */ +export function shipRiskText({ risk, confirmed = false } = {}) { + if (!risk?.classes?.length) return ''; + const head = confirmed && risk.raising?.length + ? 'Риск по участкам (трек подтверждён владельцем, не повышен)' + : 'Риск по участкам (visual ship не повышает)'; + return `${head}: ${risk.classes.map((cls) => riskClassLine(risk, cls)).join(' · ')}\n`; +} + +/** Комментарий повышения ship → show: рамки и риск одним комментарием. */ +export function raiseComment({ violations = [], risk = null, runUrl = '' } = {}) { + const parts = ['**Трек повышен: `track:ship` → `track:show`.**']; + if (violations.length) parts.push(`Правка выходит за механические рамки ship (PROCESS.md §5): ${violations.join('; ')}.`); + if (risk?.raising?.length) { + parts.push([ + 'Изменённые участки несут риск, который ship без ревью не пропускает (PROCESS.md §5, #707):', + ...risk.raising.map((cls) => `- ${riskClassLine(risk, cls)}`), + ].join('\n')); + } + parts.push('Слияние без ревью модели для неё закрыто. Этот заход идёт по треку show: лёгкий Validate и ревью модели «корректность и AC».'); + parts.push(violations.length + ? 'Понизить трек обратно может только владелец.' + : 'Понизить трек может только владелец; подтвердить ship — строкой `Трек: ship — решение владельца` в комментарии и снова `S7-code-review`.'); + if (runUrl) parts.push(`[Прогон](${runUrl}).`); + return `${parts.join('\n\n')}\n`; +} + +/** + * Решение шага трека на S7 (К3 #707) — чистая функция над метками, диффом и + * комментариями. Этап `spec`, отсутствие ветки и инфраструктурный дифф дают + * пустой риск и прежнее поведение. + */ +export function decideTrack({ + stage = 'code', branch = '', labels = [], files = [], numstat = [], nameStatus = [], diff = '', + comments = [], owner = '', runUrl = '', +} = {}) { + const base = resolveTrack({ labels, files }); + const code = stage === 'code' && Boolean(branch); + const risk = code ? classifyRisk(diff) : emptyRisk(); + const origin = trackOrigin({ labels, comments, owner, infrastructure: base.infrastructure }); + let { track } = base; + let ship = false; let raise = false; let comment = ''; let violations = []; + if (track === 'ship' && code) { + violations = shipLimitViolations({ numstat, nameStatus }); + const riskRaises = risk.raising.length > 0 && !origin.confirmed; + if (violations.length || riskRaises) { + raise = true; track = 'show'; + comment = raiseComment({ violations, risk, runUrl }); + } else { + ship = true; + } + } + const confirmed = origin.confirmed && track === origin.track; + const reasons = [violations.length && 'рамки ship', raise && risk.raising.length && `риск ${risk.raising.join(', ')}`].filter(Boolean); + const basis = raise ? `повышен конвейером с ship (${reasons.join(' и ')}); было: ${origin.basis}` : origin.basis; + return { + track, mutants: base.mutants, full: base.full, infrastructure: base.infrastructure, ship, raise, comment, violations, + risk, confirmed, basis, warning: origin.warning, + note: code && !ship ? riskNote({ track, confirmed, risk, labels }) : '', + shipRisk: ship ? shipRiskText({ risk, confirmed: origin.confirmed }) : '', + }; +} + function git(args) { - const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`); return r.stdout; } +/** Комментарии из файла `gh issue view --json comments`; пустой или битый файл — недоступны. */ +export function readComments(path) { + if (!path || !existsSync(path)) return null; + const text = readFileSync(path, 'utf8').trim(); + if (!text) return null; + try { return normalizeComments(JSON.parse(text)); } catch { return null; } +} + 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 labels = value('labels').split(',').map((s) => s.trim()).filter(Boolean); 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`); @@ -124,8 +349,50 @@ if (isMainModule(import.meta.url)) { 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); + if (command === 'stage') { + const branch = value('branch'); + const stage = value('stage') || 'code'; + const out = value('out'); + if (!out) throw new Error('--out is required'); + const diffRange = branch ? `${base || 'origin/dev'}...${head}` : null; + const code = stage === 'code' && diffRange; + const decision = decideTrack({ + stage, branch, labels, + files: diffRange ? git(['diff', '--name-only', diffRange]).split('\n').filter(Boolean) : [], + numstat: code ? parseNumstat(git(['diff', '--numstat', diffRange])) : [], + nameStatus: code ? parseNameStatus(git(['diff', '--name-status', diffRange])) : [], + diff: code ? git(['-c', 'core.quotePath=false', 'diff', '--unified=0', '-M', '--no-color', '--no-ext-diff', '--no-textconv', diffRange]) : '', + comments: readComments(value('comments')), + owner: value('owner'), + runUrl: value('run-url'), + }); + mkdirSync(out, { recursive: true }); + if (decision.raise) writeFileSync(join(out, 'raise.md'), decision.comment); + const basis = `${decision.basis}${decision.warning ? ` · внимание: ${decision.warning}` : ''}`; + const lines = [ + `track=${decision.track}`, `mutants=${decision.mutants}`, `full=${decision.full}`, `ship=${decision.ship}`, + `raise=${decision.raise}`, `basis=${basis}`, `risk=${decision.risk.classes.join(',')}`, + ]; + for (const line of lines) console.log(line); + if (decision.violations.length) console.log(`violations=${decision.violations.join('; ')}`); + if (decision.note) console.log(`risk_note:\n${decision.note}`); + if (process.env.GITHUB_OUTPUT) { + const block = (name, text) => { + if (!text) return ''; + const delimiter = `HP_TRACK_${randomUUID()}`; + return `${name}<<${delimiter}\n${text}\n${delimiter}\n`; + }; + appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n${block('risk_note', decision.note)}${block('ship_risk', decision.shipRisk)}`); + } + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- трек **${decision.track}** · основание: ${basis} · риск по участкам: ${decision.risk.classes.join(', ') || 'нет'} · полный набор: ${decision.full} · слияние без модели: ${decision.ship}${decision.raise ? ' · повышен ship → show' : ''}\n`); + } + } else if (command === 'limit') { + const path = value('files'); + const files = path && existsSync(path) ? readFileSync(path, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean) : []; + const { track, limit, infrastructure } = guardLimit({ labels, files }); + for (const line of [`track=${track}`, `limit=${limit}`, `infrastructure=${infrastructure}`]) console.log(line); + } else if (command === 'resolve') { 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}`]); @@ -137,7 +404,7 @@ if (isMainModule(import.meta.url)) { }); emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]); } else { - throw new Error('usage: process-track.mjs resolve --labels=a,b [--base= --head=] | ship-limits --base= [--head=]'); + throw new Error('usage: process-track.mjs stage --stage=code|spec --labels=a,b --branch= --base= --out= [--head --comments --owner --run-url] | limit --labels=a,b [--files=] | resolve --labels=a,b [--base= --head=] | ship-limits --base= [--head=]'); } } catch (error) { console.error(`::error::${error.message}`); diff --git a/scripts/ship-review.mjs b/scripts/ship-review.mjs index 37677304..3fa882ad 100644 --- a/scripts/ship-review.mjs +++ b/scripts/ship-review.mjs @@ -29,6 +29,8 @@ import { issueTrailers, readCandidateHistory } from './release-membership.mjs'; export const SHIP_REVIEW_DIR = 'docs/reviews'; export const SHIP_MERGE_MARKER_RE = //; +/** #707: риск по участкам, с которым ship слит (трек подтверждён владельцем или риск только visual). */ +export const SHIP_RISK_MARKER_RE = //; export const SHIP_REVIEW_ANCHOR = ''; 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}$/; @@ -46,6 +48,21 @@ export function isShipIssue({ labels = [], comments = [] } = {}) { return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? ''))); } +/** + * Строка риска из последнего комментария слияния ship (#707): `{ classes, line }` + * или null. Комментарии задач до #707 строки не несут — риск не записан. + */ +export function shipRiskFrom(comments = []) { + const merges = (comments || []).map((c) => String(c?.body ?? '')).filter((body) => SHIP_MERGE_MARKER_RE.test(body)); + const body = merges.at(-1); + const marker = body && SHIP_RISK_MARKER_RE.exec(body); + if (!marker) return null; + const classes = marker[1].split(',').filter(Boolean); + const line = body.split('\n').map((l) => l.trim()).find((l) => l.startsWith('Риск по участкам')) + || `Риск по участкам: ${classes.join(', ')}`; + return { classes, line }; +} + /** Раздел `## ТЗ` тела issue — для ship это строка «что меняется и чем проверить». */ export function specSection(body = '') { const text = String(body); @@ -77,7 +94,8 @@ export function shipIssuesInRange({ commits = [], issueData }) { 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() }); + const risk = shipRiskFrom(data.comments); + out.push({ number, title: data.title || '', spec: specSection(data.body), ...(risk ? { risk } : {}), commits: list.reverse() }); } return out; } @@ -97,6 +115,7 @@ export function renderShipBrief({ tag, candidate, base, ship, runUrl = '' }) { for (const issue of ship) { lines.push(`### #${issue.number} · ${issue.title}`, ''); lines.push(issue.spec ? issue.spec : '(раздела «## ТЗ» в теле нет — ТЗ задачи не записано, это находка)', ''); + if (issue.risk?.line) lines.push(issue.risk.line, ''); lines.push('Коммиты (`git show `):', ''); for (const commit of issue.commits) lines.push(`- \`${commit.sha}\` ${commit.subject}`); lines.push(''); diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index 2cdad378..e1746f8e 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -7,9 +7,11 @@ // свои ошибки («метка встала раньше push'а», «ревьюер читал не тот SHA»). // Скрипт делает эту сборку детерминированно и печатает один markdown-пакет: // -// issue · статус и трек · что можно делать в этом статусе · решения владельца · -// материал (ветка, SHA, база, Validate) · предыдущий вердикт · AC → свидетель · -// непроверенное / следующий шаг +// issue · статус и трек · трек: основание, лимит, ребейз (#707) · что можно +// делать в этом статусе · решения владельца · материал (ветка, SHA, база, +// Validate) · следующий шаг по ветке · риск по участкам · обязательные +// проверки · changelog и визуальное свидетельство · предыдущий вердикт · +// AC → свидетель · непроверенное // // Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не // решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами. @@ -20,7 +22,10 @@ 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'; +import { + classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, riskClassLine, trackFromLabels, trackOrigin, +} from './process-track.mjs'; +import { selectSmokes } from './smoke-select.mjs'; export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; @@ -128,7 +133,101 @@ 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 }; +// С #707 оттуда же основание трека, лимит циклов, политика ребейза и риск по +// изменённым участкам: пакет не держит своей копии правила. +export { classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, trackFromLabels, trackOrigin }; + +/** Зеркала junction limits: правка любого требует parity (§8, #548). */ +export const JUNCTION_MIRRORS = Object.freeze([ + 'src/junction-limits.ts', 'custom_components/houseplan/junction_limits.py', 'test/fixtures/junction-limits-parity.json', +]); +const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md']; +const JUNCTION_PARITY = 'npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs && python tests_backend/junction_parity.py --build-dir=test-build/junction-parity'; + +/** + * Чистота слияния ветки с `dev` без касания рабочей копии: + * `git merge-tree --write-tree` (git 2.38+). `clean: null` — проверить нельзя + * (старый git или сбой), и это не «конфликта нет». + */ +export function readMergeState({ cwd, onto = 'origin/dev', ref, run = spawnSync } = {}) { + const r = run('git', ['merge-tree', '--write-tree', '--name-only', '--no-messages', onto, ref], { cwd, encoding: 'utf8' }); + if (r?.error || (r?.status !== 0 && r?.status !== 1)) return { clean: null, conflicts: [] }; + if (r.status === 0) return { clean: true, conflicts: [] }; + const conflicts = []; + for (const line of String(r.stdout || '').split('\n').slice(1)) { + if (!line.trim()) break; + if (!conflicts.includes(line.trim())) conflicts.push(line.trim()); + } + return { clean: false, conflicts }; +} + +/** Следующий шаг по положению ветки относительно `dev` (#707): без лишнего ребейза. */ +export function nextStepLines({ branch = null, track = 'ask' } = {}) { + if (!branch || !(branch.behind > 0)) return []; + const behind = `позади dev на ${branch.behind}`; + if (branch.mergeClean === true) { + return [track === 'ask' + ? `${behind}, слияние чистое — конвейер сам приведёт ветку к dev до ревью` + : `${behind}, слияние чистое — ребейз не нужен: один раз при слиянии`]; + } + if (branch.mergeClean === false) { + const files = branch.conflicts?.length ? branch.conflicts.join(', ') : 'git не назвал файлы'; + return [`слияние с dev конфликтует: ${files} — ребейз до S7 (\`node scripts/rebase-on-dev.mjs\`)`]; + } + return [`${behind}: чистота слияния не проверена (\`git merge-tree --write-tree\` недоступен, нужен git 2.38+)`]; +} + +/** Следствие риска по треку (#707): что сделает конвейер или ревьюер. */ +export function riskConsequence({ track, confirmed = false, risk } = {}) { + if (!risk?.raising?.length) return 'справочно: visual трек не повышает (§5)'; + if (track === 'ship') return confirmed ? 'не повысит; риск прочтёт пакетное ревью' : 'конвейер повысит до show при S7'; + if (track === 'show') return 'ревьюер спросит, где поведение зафиксировано; нет ссылки — повысить до ask до S7 (§5)'; + return 'справочно'; +} + +/** + * Обязательные проверки «команда · основание» (#707, §8). `ci:golden` — только + * при визуальном риске в пути отрисовки: CSS интерфейса, комментарии и тесты + * кадров плана не двигают. + */ +export function requiredChecks({ risk = null, changedFiles = [], smokes = null, labels = [] } = {}) { + const out = [{ command: '`npm run gate:small`', reason: 'всегда (§8)' }]; + for (const entry of smokes?.direct || []) { + out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: прямое совпадение (${entry.symbols.slice(0, 4).join(', ')})` }); + } + for (const entry of smokes?.registered || []) { + out.push({ command: `\`node demo/${entry.smoke}\``, reason: `smoke-select: зарегистрированная связь (${entry.symbols.slice(0, 4).join(', ')})` }); + } + if (smokes?.visualMinimum?.length) { + out.push({ command: '`npm run gate:small -- --smokes`', reason: `smoke-select: визуальный минимум — связь диффа со смоками не доказана (#690): ${smokes.visualMinimum.map((s) => s.replace(/\.mjs$/, '')).join(', ')}` }); + } + if (risk?.classes?.includes('geometry')) { + out.push({ command: '`npm run invariants -- --config <экспорт>`', reason: 'риск geometry (§8, #254)' }); + } + const python = changedFiles.filter((file) => /^custom_components\/.+\.py$/.test(file)); + if (python.length) { + out.push({ command: '`python -m pytest tests_backend -q`', reason: `изменён Python: ${python.slice(0, 3).join(', ')}${python.length > 3 ? ` и ещё ${python.length - 3}` : ''}` }); + } + const mirrors = changedFiles.filter((file) => JUNCTION_MIRRORS.includes(file)); + if (mirrors.length) out.push({ command: `\`${JUNCTION_PARITY}\``, reason: `junction parity: изменено зеркало ${mirrors.join(', ')} (§8)` }); + if (risk?.visual?.render) { + out.push({ command: 'метка `ci:golden`', reason: labels.includes('ci:golden') + ? 'стоит: golden на ветке и приёмка сдвинутых кадров в задаче (§5.1)' + : 'рекомендовано, если сдвиг кадров намерен: визуальный риск в пути отрисовки (§5.1, §8)' }); + } + return out; +} + +/** Трейлеры `User-Visible` коммитов ветки и changelog в её диффе (§3 п.10, §7.1). */ +export function changelogState({ commits = [], changedFiles = [], risk = null } = {}) { + const yes = commits.filter((c) => /^User-Visible:\s*yes\s*$/im.test(String(c.message ?? ''))).length; + const no = commits.filter((c) => /^User-Visible:\s*no\s*$/im.test(String(c.message ?? ''))).length; + return { + yes, no, + missing: yes ? CHANGELOGS.filter((file) => !changedFiles.includes(file)) : [], + visualEvidence: Boolean(risk?.visual?.render), + }; +} /** * Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток @@ -168,13 +267,26 @@ 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'; + // `show`; явная метка главнее. Маршрут при этом остаётся инфраструктурным — + // вход сразу на S7, без S1–S5. Трек, основание и лимит — те же функции, что + // у конвейера (#707). + const origin = trackOrigin({ labels, comments, owner, infrastructure: infrastructure || infrastructureHint }); const track = infrastructure - ? `инфраструктурный · ${infraTrack}` - : infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)` - : trackFromLabels(labels); + ? `инфраструктурный · ${origin.track}` + : infrastructureHint ? `инфраструктурный · ${origin.track} (предварительно; подтвердить путями/diff)` + : origin.track; + const mergeClean = branch ? (branch.behind > 0 ? (branch.mergeClean ?? null) : true) : null; + const trackDetail = { + track: origin.track, basis: origin.basis, warning: origin.warning, confirmed: origin.confirmed, + limit: cycleLimit(origin.track), + // null — политика зависит от чистоты слияния, а её здесь не проверить. + rebaseBeforeReview: origin.track !== 'ask' && mergeClean === null ? null : rebaseBeforeReview(origin.track, mergeClean), + }; + const risk = !branch ? { computed: false, reason: 'не посчитан: ветки нет' } + : typeof branch.diff !== 'string' ? { computed: false, reason: 'не посчитан: дифф ветки не прочитан' } + : { computed: true, ...classifyRisk(branch.diff) }; + if (risk.computed && risk.classes.length) risk.consequence = riskConsequence({ track: origin.track, confirmed: origin.confirmed, risk }); + const changedFiles = branch?.changedFiles || []; const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code'; const verdict = lastVerdict(comments, reviewDocs, stage); // ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до @@ -188,13 +300,17 @@ export function buildPacket(inputs) { const unverified = acs.filter((a) => a.evidence.startsWith('без записи')); const packet = { issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url }, - status, track, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }), + status, track, trackDetail, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }), decisions: ownerDecisions(comments, owner), material: branch ? { branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind, treeMatchesVerdict: verdict.doc?.tree ? branch.treeWithoutReviews === verdict.doc.tree : null, validate: validate || { status: 'неизвестно' }, } : null, + nextStep: nextStepLines({ branch: branch ? { ...branch, mergeClean } : null, track: origin.track }), + risk, + checks: requiredChecks({ risk: risk.computed ? risk : null, changedFiles, smokes: branch?.smokes ?? null, labels }), + changelog: branch ? changelogState({ commits: branch.commits || [], changedFiles, risk: risk.computed ? risk : null }) : null, verdict, acceptance: acs, unverified: unverified.map((a) => a.id), @@ -209,6 +325,15 @@ export function renderPacket(p) { L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`); if (p.productFlow?.length) L.push(`Продуктовый поток: ${p.productFlow.join(', ')} — дифф без класса A трек не меняет (#632)`); L.push(''); + if (p.trackDetail) { + const t = p.trackDetail; + const rebase = t.rebaseBeforeReview === true ? 'да' : t.rebaseBeforeReview === false ? 'нет' : 'только при конфликте с dev (чистота слияния не проверена)'; + L.push('## Трек'); + L.push(`- ${t.track} · основание: ${t.basis}`); + if (t.warning) L.push(`- внимание: ${t.warning}`); + L.push(`- лимит циклов код-ревью: ${t.limit} · ребейз до ревью: ${rebase}`); + L.push(''); + } L.push('## Права и следующий шаг'); for (const r of p.rights) L.push(`- ${r}`); L.push(''); @@ -220,12 +345,39 @@ export function renderPacket(p) { if (!p.material) L.push('- ветки issue/NN-* на origin нет — материал не запушен'); else { const m = p.material; - L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}${m.behind ? ' — перед S7 ребейз (rebase-on-dev.mjs при конфликте в бандле)' : ''}`); + L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}`); L.push(`- Validate на вершине: ${m.validate.status}${m.validate.url ? ` (${m.validate.url})` : ''}`); if (m.treeMatchesVerdict === true) L.push('- дерево вне docs/reviews совпадает с материалом последнего вердикта — повторный S7 применит его без модели (#499)'); if (m.treeMatchesVerdict === false) L.push('- дерево изменилось с последнего вердикта — будет полный разбор'); } L.push(''); + if (p.nextStep?.length) { + L.push('## Следующий шаг'); + for (const line of p.nextStep) L.push(`- ${line}`); + L.push(''); + } + if (p.risk && (!p.risk.computed ? p.risk.reason === 'не посчитан: ветки нет' : p.risk.classes.length)) { + L.push('## Риск по участкам'); + if (!p.risk.computed) L.push(`- ${p.risk.reason}`); + else { + for (const cls of p.risk.classes) L.push(`- ${riskClassLine(p.risk, cls)}`); + L.push(`- следствие: ${p.risk.consequence}`); + } + L.push(''); + } + if (p.checks?.length) { + L.push('## Обязательные проверки'); + for (const check of p.checks) L.push(`- ${check.command} · ${check.reason}`); + L.push(''); + } + const c = p.changelog; + if (c && (c.yes || c.no || c.visualEvidence)) { + L.push('## Changelog и визуальное свидетельство'); + if (c.yes || c.no) L.push(`- коммитов ветки: \`User-Visible: yes\` — ${c.yes}, \`User-Visible: no\` — ${c.no}`); + for (const file of c.missing) L.push(`- не хватает ${file}: \`User-Visible: yes\` требует правок в обоих changelog (§3 п.10)`); + if (c.visualEvidence) L.push('- визуальный риск в пути отрисовки: дефект растра или резкости требует свидетеля, красного на старом коде, и подтверждения владельца в GPU-браузере (§7.1)'); + L.push(''); + } L.push('## Предыдущий вердикт'); if (p.verdict.comment) L.push(`- комментарий: ${p.verdict.comment.line}${p.verdict.comment.url ? ` (${p.verdict.comment.url})` : ''}`); if (p.verdict.doc) L.push(`- документ: \`${p.verdict.doc.name}\`, дерево \`${p.verdict.doc.tree?.slice(0, 12) || '—'}\`, запись конвейера: ${p.verdict.doc.recorded ? `${p.verdict.doc.recorded.verdict} · High ${p.verdict.doc.recorded.high}` : 'нет (документ до #499)'}`); @@ -265,6 +417,21 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd })); const changedFiles = sh('git', ['diff', '--name-only', `${base}..${ref}`], { cwd }).split('\n').filter(Boolean); const infrastructure = branchIsInfrastructure(changedFiles); + // #707: риск по изменённым участкам, смоки и трейлеры — по тому же диффу от + // merge-base, что судит конвейер на S7. + const diff = sh('git', ['-c', 'core.quotePath=false', 'diff', '--unified=0', '-M', '--no-color', '--no-ext-diff', '--no-textconv', `${base}..${ref}`], { cwd }); + const commits = sh('git', ['log', '--format=%B%x1e', `${base}..${ref}`], { cwd }) + .split('\x1e').map((message) => message.trim()).filter(Boolean).map((message) => ({ message })); + const merge = behind > 0 ? readMergeState({ cwd, onto: 'origin/dev', ref }) : { clean: true, conflicts: [] }; + let smokes = null; + try { + const selection = selectSmokes(diff); + smokes = { + direct: selection.direct.filter((entry) => entry.strong).map(({ smoke, symbols }) => ({ smoke, symbols })), + registered: selection.registered.map(({ smoke, symbols }) => ({ smoke, symbols })), + visualMinimum: selection.visualMinimum, + }; + } catch { smokes = null; } // Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт, // поэтому сравнение делается diff'ом при известном якоре (см. ниже). const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n)); @@ -277,7 +444,10 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { cwd }); treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`; } - branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure }; + branch = { + name, tip, base, ahead, behind, treeWithoutReviews, infrastructure, + changedFiles, diff, commits, smokes, mergeClean: merge.clean, conflicts: merge.conflicts, + }; } let validate = null; if (branch) { diff --git a/test/process-digests.test.mjs b/test/process-digests.test.mjs index 0c3b144c..f2da6b0c 100644 --- a/test/process-digests.test.mjs +++ b/test/process-digests.test.mjs @@ -48,6 +48,10 @@ const KEY_RULES = { ['3-правила', 'Статус меняется до действия, а не после'], ['5-треки-ship-show-ask--метка-владельца', 'Метка владельца главнее критериев'], ['5-треки-ship-show-ask--метка-владельца', 'ожидаемое поведение уже зафиксировано'], + // #707: трек подтверждает строка владельца, метка без неё — предложение. + ['5-треки-ship-show-ask--метка-владельца', 'строкой `Трек: — решение владельца` в начале строки'], + ['5-треки-ship-show-ask--метка-владельца', 'Метка без такой строки — предложение'], + ['5-треки-ship-show-ask--метка-владельца', 'Агент эту строку не пишет никогда'], ['71-цепочка', 'Владельцу задаются только продуктовые вопросы'], ['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'], ['26-в-разработке--реализация', 'каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #` и `User-Visible: yes|no`'], diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index 0ef5c931..a0cfae02 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -1,13 +1,18 @@ 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 { existsSync, mkdtempSync, readFileSync, 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, + COMPARE_FILES_CAP, RISK_CLASSES, RISK_NOTE_LINE_LIMIT, SHIP_SRC_LINE_LIMIT, classifyRisk, cycleLimit, decideTrack, explicitTracks, + guardLimit, hasTrackLabel, parseNameStatus, parseNumstat, rebaseBeforeReview, resolveTrack, riskClassLine, riskNote, + shipLimitViolations, trackFromLabels, trackOrigin, } from '../scripts/process-track.mjs'; +import { classify as classifyPath } from '../scripts/change-classes.mjs'; +// Пути монолитов — данные для классификатора, а не чтение их текста (#624). +import { CARD_FILE, RUNTIME_FILE } from '../scripts/monolith-metrics.mjs'; import { trackFromLabels as packetTrack } from '../scripts/task-packet.mjs'; // #696: конвейер ревью решает цену захода по треку; трек и рамки ship — @@ -131,10 +136,13 @@ test('конвейер: трек снимается до ребейза, мут 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 — только в рамках'); + // #707: один вызов скрипта решает трек, рамки ship и риск; bash только исполняет. + assert.equal((trackStep.match(/process-track\.mjs/g) || []).length, 1, 'скрипт трека вызывается один раз'); + assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" stage --stage="\$STAGE" --labels="\$LABELS" \\\n\s+--branch="\$BRANCH" --base=origin\/dev --head=HEAD --comments="\$comments" --owner="\$OWNER"/); + assert.match(trackStep, /if printf '%s\\n' "\$out" \| grep -qx 'raise=true'; then\n\s+gh issue comment "\$NUM" --repo "\$\{\{ github\.repository \}\}" --body-file "\$RUNNER_TEMP\/track\/raise\.md"/, + 'комментарий повышения — из файла скрипта, только по его флагу'); assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек'); + assert.doesNotMatch(trackStep, /ship=true|track=show|>> "\$GITHUB_OUTPUT"/, 'выходы шага пишет скрипт, а не bash'); 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/, @@ -181,5 +189,561 @@ test('#697: конвейер передаёт полный набор гейту 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'\)/); + // #707: `full` пишет в GITHUB_OUTPUT скрипт трека — из меток и только из них. + assert.match(readFileSync(SCRIPT, 'utf8'), /`full=\$\{decision\.full\}`/); +}); + +// ---------- #707: риск по изменённым участкам, основание трека, одно правило ---------- + +/** Дифф `git diff --unified=0` из строк: `add`/`del` — пары [номер, текст]. */ +function diffOf(files) { + return `${files.map(({ path, oldPath = path, add = [], del = [] }) => { + const out = [`diff --git a/${oldPath} b/${path}`, `--- a/${oldPath}`, `+++ b/${path}`]; + for (const [line, text] of del) out.push(`@@ -${line} +${line - 1},0 @@`, `-${text}`); + for (const [line, text] of add) out.push(`@@ -${line - 1},0 +${line} @@`, `+${text}`); + return out.join('\n'); + }).join('\n')}\n`; +} +const only = (risk) => ({ classes: risk.classes, evidence: risk.evidence }); + +test('#707 AC1: классификатор риска — точные классы и доказательства путь:строка', () => { + const card = CARD_FILE; + // (а) токен в монолите — touch; удаление такой строки — тоже touch. + assert.deepEqual(only(classifyRisk(diffOf([{ path: card, add: [[12, " this.addEventListener('pointerdown', onDown);"]] }]))), + { classes: ['touch'], evidence: { touch: [`${card}:12 · токен pointerdown`] } }); + assert.deepEqual(only(classifyRisk(diffOf([{ path: card, del: [[40, " el.removeEventListener('pointerdown', onDown);"]] }]))), + { classes: ['touch'], evidence: { touch: [`${card}:40 (удалена) · токен pointerdown`] } }); + // (б) строка-комментарий в участке geometry — ничего; код там же — geometry по участку. + const wall = 'src/wall-merge.ts'; + for (const comment of ['// pointerdown snapToGrid thickness', '/* merge */', ' * @param wall', ' */', '', ' ']) { + assert.deepEqual(classifyRisk(diffOf([{ path: wall, add: [[5, comment]] }])).classes, [], JSON.stringify(comment)); + } + assert.deepEqual(only(classifyRisk(diffOf([{ path: wall, add: [[5, ' const merged = a + b;']] }]))), + { classes: ['geometry'], evidence: { geometry: [`${wall}:5 · участок wall-*`] } }); + // (в) snapshot — не geometry; snapToGrid — geometry по токену даже в монолите. + assert.deepEqual(classifyRisk(diffOf([{ path: card, add: [[3, ' const snapshot = this._snapshot();']] }])).classes, []); + assert.deepEqual(only(classifyRisk(diffOf([{ path: card, add: [[3, ' const p = snapToGrid(pt, 5);']] }]))), + { classes: ['geometry'], evidence: { geometry: [`${card}:3 · токен snapToGrid`] } }); + // (г) новый ключ i18n — ux; смена только значения — ничего. + assert.deepEqual(only(classifyRisk(diffOf([{ path: 'src/i18n/en.json', add: [[9, ' "save_plan": "Save plan",']] }]))), + { classes: ['ux'], evidence: { ux: ['src/i18n/en.json:9 · новый ключ "save_plan"'] } }); + assert.deepEqual(classifyRisk(diffOf([{ path: 'src/i18n/en.json', del: [[9, ' "save_plan": "Save",']], add: [[9, ' "save_plan": "Save plan",']] }])).classes, []); + // (д) callService в участке devices — devices по участку и токену. + assert.deepEqual(only(classifyRisk(diffOf([{ path: 'src/device-toggle.ts', add: [[3, " hass.callService('light', 'toggle', data);"]] }]))), + { classes: ['devices'], evidence: { devices: ['src/device-toggle.ts:3 · участок device-toggle, токен callService'] } }); + // (е) те же токены вне класса A — ничего. + for (const path of ['test/touch.test.mjs', 'scripts/tool.mjs', 'docs/TOUCH-SUPPORT.md', 'demo/smoke_x.mjs']) { + assert.deepEqual(classifyRisk(diffOf([{ path, add: [[1, "on('pointerdown', () => hass.callService(snapToGrid(requestAnimationFrame)))"]] }])).classes, [], path); + } + // (ж) декларация стиля интерфейса — visual/ui, ship не повышает. + const ui = classifyRisk(diffOf([{ path: 'src/styles/plan.styles.ts', add: [[7, ' gap: 4px;']] }])); + assert.deepEqual(ui.classes, ['visual']); + assert.deepEqual(ui.raising, []); + assert.deepEqual(ui.visual, { render: false, ui: true }); + assert.deepEqual(ui.evidence.visual, ['src/styles/plan.styles.ts:7 · участок src/styles/** (ui)']); + // (з) путь отрисовки плана — visual/render и perf. + const render = classifyRisk(diffOf([{ path: 'src/render/paper-scene.ts', add: [[3, ' const scale = 2;']] }])); + assert.deepEqual(only(render), { + classes: ['perf', 'visual'], + evidence: { perf: ['src/render/paper-scene.ts:3 · участок src/render/**'], visual: ['src/render/paper-scene.ts:3 · участок src/render/** (render)'] }, + }); + assert.deepEqual(render.visual, { render: true, ui: false }); + assert.deepEqual(render.raising, ['perf']); + // (и) чистое переименование — ханков нет, риска нет. + const rename = 'diff --git a/src/wall-merge.ts b/src/wall-merge-core.ts\nsimilarity index 100%\nrename from src/wall-merge.ts\nrename to src/wall-merge-core.ts\n'; + assert.deepEqual(classifyRisk(rename).classes, []); + // Двоичный файл ханков не даёт — его ловят рамки ship. + assert.deepEqual(classifyRisk('diff --git a/src/render/icon.png b/src/render/icon.png\nindex 1..2 100644\nBinary files a/src/render/icon.png and b/src/render/icon.png differ\n').classes, []); + // (к) переводы интеграции — ux; strings.json класса «?» не судится. + const key = ' "zone_name": "Zone",'; + assert.deepEqual(only(classifyRisk(diffOf([{ path: 'custom_components/houseplan/translations/en.json', add: [[4, key]] }]))), + { classes: ['ux'], evidence: { ux: ['custom_components/houseplan/translations/en.json:4 · новый ключ "zone_name"'] } }); + assert.deepEqual(classifyRisk(diffOf([{ path: 'custom_components/houseplan/strings.json', add: [[4, key]] }])).classes, []); +}); + +test('#707 AC1: каждая строка таблицы риска — положительный и отрицательный случай', () => { + const cls = (path, add, del = []) => classifyRisk(diffOf([{ path, add, del }])).classes; + const card = CARD_FILE; + const cases = [ + // [класс, положительный участок, положительный токен, отрицательный] + ['geometry', ['src/opening-placement.ts', 'x = 1;'], [card, 'const t = wallThickness;'], [card, 'const s = snapshotOf(x);']], + ['geometry', ['custom_components/houseplan/junction_limits.py', 'x = 1'], ['src/logic.ts', 'canonicalizePoint(p);'], ['custom_components/houseplan/junction_limits.py', '# junction comment']], + ['touch', ['src/pointer-modality.ts', 'x = 1;'], [card, "style: 'touch-action: none'"], ['src/logic.ts', 'const pointerUpdate = 1;']], + ['migration', ['src/config-store.ts', 'x = 1;'], [card, 'migrateLegacy(cfg);'], ['scripts/migrate.mjs', 'migrateLegacy(cfg);']], + ['migration', ['custom_components/houseplan/store.py', 'x = 1'], ['custom_components/houseplan/plans.py', 'STORAGE_VERSION = 3'], ['custom_components/houseplan/store.py', ' ']], + ['devices', ['src/vacuum-routes.ts', 'x = 1;'], [card, "hass.callService('a', 'b');"], ['docs/x.md', "hass.callService('a', 'b');"]], + ['devices', ['custom_components/houseplan/auth.py', 'x = 1'], ['custom_components/houseplan/plans.py', 'require_admin(user)'], ['custom_components/houseplan/plans.py', 'user = 1']], + ['perf', ['src/glow-scene.ts', 'x = 1;'], [card, 'const r = el.getBoundingClientRect();'], [card, '// getBoundingClientRect']], + ['perf', ['src/boot-soft-layout.ts', 'x = 1;'], ['src/logic.ts', ' filter: blur(2px);'], ['src/logic.ts', 'list.filter((x) => x);']], + ['ux', null, [card, "customElements.define('hp-x', X);"], [card, "customElements.get('hp-x');"]], + ['visual', ['src/iso-walls.ts', 'x = 1;'], [card, ''], [card, 'const strokeWidthPx = 2;']], + ['visual', ['src/summary-panel-style.ts', 'x = 1;'], ['src/furniture-plan-art.generated.ts', 'x = 1;'], [card, 'const styles = 1;']], + ]; + for (const [name, area, token, negative] of cases) { + if (area) assert.ok(cls(area[0], [[1, area[1]]]).includes(name), `${name}: участок ${area[0]}`); + assert.ok(cls(token[0], [[1, token[1]]]).includes(name), `${name}: ${token[0]} «${token[1]}»`); + assert.ok(!cls(negative[0], [[1, negative[1]]]).includes(name), `${name}: не должно — ${negative[0]} «${negative[1]}»`); + } + // ux: удалённый customElements.define и удалённый ключ риска не дают. + assert.deepEqual(cls(card, [], [[3, "customElements.define('hp-x', X);"]]), []); + assert.deepEqual(cls('src/i18n/settings/ru.json', [], [[3, ' "a": "b",']]), []); + assert.deepEqual(cls('src/i18n/settings/ru.json', [[3, ' "a": "b",']]), ['ux'], 'src/i18n/**/*.json'); + // монолит участком не судится: нейтральная строка — без классов. + assert.deepEqual(cls(RUNTIME_FILE, [[3, 'const a = 1;']]), []); + // текст перевода со словом токена — не геометрия. + assert.deepEqual(cls('src/i18n/en.json', [[3, ' "wall_thickness": "Wall thickness",']], [[3, ' "wall_thickness": "Thickness",']]), []); +}); + +test('#707 AC1: большой дифф — классифицируются все ханки, печатается не больше пяти', () => { + const add = Array.from({ length: 12 }, (_, i) => [i + 1, ` el.addEventListener('pointermove', f${i});`]); + const risk = classifyRisk(diffOf([{ path: CARD_FILE, add }])); + assert.equal(risk.counts.touch, 12); + assert.equal(risk.evidence.touch.length, 5); + assert.match(riskClassLine(risk, 'touch'), /; и ещё 7$/); +}); + +test('#707 AC2: происхождение трека — строка владельца, предложение, прежние метки', () => { + const c = (author, body, createdAt) => ({ author, body, createdAt }); + const owner = 'Matysh'; + const ship = ['track:ship', 'S7-code-review']; + const confirmed = trackOrigin({ labels: ship, owner, comments: [c(owner, 'Решение.\nТрек: ship — решение владельца', '2026-09-30T08:00:00Z')] }); + assert.equal(confirmed.confirmed, true); + assert.equal(confirmed.basis, 'метка, подтверждённая владельцем (2026-09-30)'); + for (const line of ['трек: SHIP – Решение Владельца', 'Трек: ship - решение владельца', ' Трек:ship—решение владельца']) { + assert.equal(trackOrigin({ labels: ship, owner, comments: [c('matysh', line, '1')] }).confirmed, true, line); + } + const proposal = 'метка без подтверждения — предложение'; + assert.equal(trackOrigin({ labels: ship, owner, comments: [c('claude[bot]', 'Трек: ship — решение владельца', '1')] }).basis, proposal, 'чужой автор'); + assert.equal(trackOrigin({ labels: ship, owner, comments: [c(owner, 'Трек: show — решение владельца', '1')] }).basis, proposal, 'другой трек'); + assert.equal(trackOrigin({ labels: ship, owner, comments: [c(owner, 'как сказано: Трек: ship — решение владельца', '1')] }).basis, proposal, 'не в начале строки'); + assert.equal(trackOrigin({ labels: ship, owner, comments: [c(owner, '> Трек: ship — решение владельца', '1')] }).basis, proposal, 'цитата не подтверждение'); + // Более поздняя строка владельца отменяет раннюю — по времени, а не по порядку массива. + const later = [c(owner, 'Трек: show — решение владельца', '2026-09-30T09:00:00Z'), c(owner, 'Трек: ship — решение владельца', '2026-09-29T09:00:00Z')]; + assert.equal(trackOrigin({ labels: ship, owner, comments: later }).confirmed, false); + assert.equal(trackOrigin({ labels: ['track:show'], owner, comments: later }).confirmed, true); + // Комментарии недоступны — происхождение не установлено. + const unknown = trackOrigin({ labels: ship, owner, comments: null }); + assert.equal(unknown.confirmed, false); + assert.equal(unknown.basis, 'метка, происхождение не установлено (комментарии недоступны)'); + // Несколько трековых меток — строжайшая и предупреждение. + const two = trackOrigin({ labels: ['track:ship', 'track:ask'], owner, comments: [] }); + assert.equal(two.track, 'ask'); + assert.match(two.warning, /несколько трековых меток \(track:ask, track:ship\) — дефект разметки, действует строжайшая track:ask/); + assert.equal(trackFromLabels(['track:ship', 'track:show']), 'show'); + assert.equal(trackOrigin({ labels: ['small'], owner, comments: [] }).basis, 'прежняя метка small → show (§5.1)'); + assert.equal(trackOrigin({ labels: ['small'], owner, comments: [] }).track, 'show'); + assert.equal(trackOrigin({ labels: [], infrastructure: true }).basis, 'метки нет: инфраструктура → show'); + assert.equal(trackOrigin({ labels: ['bug'] }).basis, 'метки нет: продукт → ask'); +}); + +const RISKY = diffOf([{ path: 'src/pointer-modality.ts', add: [[7, " if (e.pointerType === 'touch') return;"]] }]); +const VISUAL = diffOf([{ path: 'src/styles/plan.styles.ts', add: [[7, ' gap: 4px;']] }]); +const small = { numstat: [{ added: 1, deleted: 0, path: 'src/pointer-modality.ts' }], nameStatus: [{ status: 'M', path: 'src/pointer-modality.ts' }] }; +const ownerSays = (track) => [{ author: 'Matysh', body: `Трек: ${track} — решение владельца`, createdAt: '2026-09-30T08:00:00Z' }]; +const s7 = (over = {}) => decideTrack({ + stage: 'code', branch: 'issue/7-x', labels: ['track:ship', 'S7-code-review'], files: ['src/pointer-modality.ts'], + ...small, diff: RISKY, comments: [], owner: 'Matysh', runUrl: 'https://run/1', ...over, +}); + +test('#707 AC3: решение по ship на S7 — рамки, риск и подтверждение владельца', () => { + // Рамки соблюдены, риск есть, подтверждения нет — show комментарием с классами и способом подтвердить ship. + const raised = s7(); + assert.equal(raised.raise, true); + assert.equal(raised.track, 'show'); + assert.equal(raised.ship, false); + assert.match(raised.comment, /^\*\*Трек повышен: `track:ship` → `track:show`\.\*\*/); + assert.match(raised.comment, /- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType/); + assert.match(raised.comment, /Понизить трек может только владелец; подтвердить ship — строкой `Трек: ship — решение владельца` в комментарии и снова `S7-code-review`/); + assert.match(raised.comment, /\[Прогон\]\(https:\/\/run\/1\)/); + assert.doesNotMatch(raised.comment, /механические рамки/, 'рамки не нарушены — их не называет'); + // Сам комментарий конвейера (он идёт от учётной записи владельца) подтверждением не служит. + assert.equal(trackOrigin({ labels: ['track:ship'], owner: 'Matysh', comments: [{ author: 'Matysh', body: raised.comment, createdAt: '2' }] }).confirmed, false); + // Подтверждено владельцем — ship, строка риска для hp:ship-merge. + const kept = s7({ comments: ownerSays('ship') }); + assert.equal(kept.raise, false); + assert.equal(kept.ship, true); + assert.equal(kept.track, 'ship'); + assert.equal(kept.note, '', 'модель на ship не зовётся — заметка не нужна'); + assert.match(kept.shipRisk, /^Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:7/); + assert.match(kept.shipRisk, /\n$/); + // Риск только visual — ship без повышения. + const visual = s7({ diff: VISUAL, files: ['src/styles/plan.styles.ts'] }); + assert.equal(visual.raise, false); + assert.equal(visual.ship, true); + assert.equal(visual.full, false, 'визуальный риск полного набора не заказывает'); + // Нарушение рамок — show при любом подтверждении; оба перечня одним комментарием. + const over = { numstat: [{ added: 40, deleted: 0, path: 'src/pointer-modality.ts' }], nameStatus: small.nameStatus }; + for (const comments of [[], ownerSays('ship')]) { + const both = s7({ ...over, comments }); + assert.equal(both.raise, true); + assert.equal(both.track, 'show'); + assert.match(both.comment, /механические рамки ship \(PROCESS\.md §5\): дифф src\/\*\* — 40 строк при рамке 30\./); + assert.match(both.comment, /- touch: src\/pointer-modality\.ts:7/); + assert.match(both.comment, /Понизить трек обратно может только владелец\./); + assert.equal((both.comment.match(/Трек повышен/g) || []).length, 1, 'один комментарий'); + } + // Повторный S7 после повышения: метка уже show — второго комментария нет. + assert.equal(s7({ labels: ['track:show', 'S7-code-review'] }).raise, false); + // Этап spec, нет ветки, инфраструктурный дифф — риск пуст, поведение прежнее. + for (const quiet of [s7({ stage: 'spec' }), s7({ branch: '' }), s7({ labels: ['S7-code-review'], files: ['scripts/x.mjs'], diff: diffOf([{ path: 'scripts/x.mjs', add: [[1, 'pointerdown']] }]) })]) { + assert.deepEqual(quiet.risk.classes, []); + assert.equal(quiet.raise, false); + assert.equal(quiet.note, ''); + } + assert.equal(s7({ stage: 'spec' }).ship, false, 'ship сливается только на код-ревью'); + // Полный набор — только по меткам. + assert.equal(s7({ labels: ['track:show', 'ci:golden'] }).full, true); +}); + +test('#707 AC5: заметка риска ревьюеру show/ask', () => { + const risk = classifyRisk(RISKY); + const showNote = riskNote({ track: 'show', risk }); + assert.match(showNote, /Трек show держится на «решать нечего» \(PROCESS\.md §5\)/); + assert.match(showNote, /не нашёл — Medium «решать есть что — нужен track:ask» с названным критерием §5/); + assert.match(showNote, /- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType/); + const confirmedNote = riskNote({ track: 'show', confirmed: true, risk }); + assert.match(confirmedNote, /трек не повышать: не нашёл — вопрос владельцу в комментарии, вариант по умолчанию «повысить до ask»/); + assert.doesNotMatch(confirmedNote, /Medium/); + assert.match(riskNote({ track: 'ask', risk }), /Трек ask: сверь, что каждый класс ниже покрыт AC ТЗ/); + const render = classifyRisk(diffOf([{ path: 'src/render/paper-scene.ts', add: [[3, ' const scale = 2;']] }])); + assert.match(riskNote({ track: 'show', risk: render }), /Визуальный риск в пути отрисовки плана без ci:golden — если задача меняет вид, нужен ci:golden/); + assert.doesNotMatch(riskNote({ track: 'show', risk: render, labels: ['ci:golden'] }), /ci:golden —/); + assert.equal(riskNote({ track: 'show', risk: classifyRisk(VISUAL) }), '', 'visual/ui — ни вопроса трека, ни golden'); + assert.equal(riskNote({ track: 'show', risk: classifyRisk('') }), ''); + const everything = classifyRisk(diffOf(RISK_CLASSES.map((_, i) => ({ + path: CARD_FILE, + add: Array.from({ length: 9 }, (__, j) => [i * 100 + j + 1, "on('pointerdown', () => snapToGrid(requestAnimationFrame(callService(migrate(x))))); customElements.define('x', X)"]), + })))); + const lines = riskNote({ track: 'show', risk: everything }).split('\n'); + assert.ok(lines.length <= RISK_NOTE_LINE_LIMIT, `${lines.length} строк`); + assert.equal(RISK_NOTE_LINE_LIMIT, 25); +}); + +/** Прежний bash guard — эталон, против которого сверяется единое правило (AC6). */ +function oldGuardLimit(labels, { branch = true, compareOk = true, files = [] } = {}) { + const has = (label) => labels.includes(label); + let small = has('small') || has('track:show') || has('track:ship'); + let trivial = has('trivial'); + if (has('track:ask')) { small = false; trivial = false; } + let limit = small || trivial ? 2 : 4; + if (branch && !['track:ship', 'track:show', 'track:ask', 'small', 'trivial'].some(has) + && compareOk && files.length > 0 && files.length < 300 && files.every((f) => classifyPath(f) !== 'A')) limit = 2; + return limit; +} + +test('#707 AC6: трек и лимит — одно правило; прежний guard воспроизведён везде, кроме нескольких трековых меток', () => { + const infra = ['scripts/a.mjs', '.github/workflows/b.yml']; + const product = ['src/a.ts', 'scripts/a.mjs']; + const many = Array.from({ length: 300 }, (_, i) => `scripts/f${i}.mjs`); + const labelSets = [[], ['infra'], ['bug', 'P2'], ['ci:golden'], ['small'], ['trivial'], ['track:ship'], ['track:show'], ['track:ask'], + ['track:ask', 'small'], ['track:ask', 'trivial'], ['track:show', 'small'], ['track:ship', 'trivial']]; + const multi = [['track:ship', 'track:ask'], ['track:ship', 'track:show'], ['track:show', 'track:ask'], ['track:ship', 'track:show', 'track:ask']]; + const worlds = [ + { name: 'инфраструктура', files: infra }, { name: 'продукт', files: product }, { name: 'пусто', files: [] }, + { name: '300 файлов', files: many }, { name: 'compare отказал', files: infra, compareOk: false }, { name: 'нет ветки', files: [], branch: false }, + ]; + for (const labels of [...labelSets, ...multi]) { + for (const world of worlds) { + const files = world.compareOk === false || world.branch === false ? [] : world.files; + const got = guardLimit({ labels, files }); + assert.equal(got.limit, cycleLimit(resolveTrack({ labels, files, filesCapped: files.length >= COMPARE_FILES_CAP }).track)); + assert.equal(got.limit, oldGuardLimit(labels, world), `${labels.join(',') || 'без меток'} · ${world.name}`); + } + } + for (const labels of multi) assert.equal(guardLimit({ labels, files: infra }).track, explicitTracks(labels)[0], `строжайшая: ${labels}`); + assert.deepEqual(guardLimit({ labels: [], files: many }), { track: 'ask', limit: 4, infrastructure: false }, '300 файлов и больше — ask/4'); + assert.deepEqual(guardLimit({ labels: [], files: infra }), { track: 'show', limit: 2, infrastructure: true }, 'инфраструктура без метки — лимит 2'); + assert.equal(cycleLimit('ask'), 4); + assert.equal(cycleLimit('show'), 2); + assert.equal(cycleLimit('ship'), 2); +}); + +test('#707 AC6: guard берёт трек и лимит из process-track.mjs и не держит своей логики трека', async () => { + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:')); + assert.match(guard, /track_out=\$\(node scripts\/process-track\.mjs limit --labels="\$labels" --files="\$changed"\)/); + assert.match(guard, /new_limit=\$\(printf '%s\\n' "\$track_out" \| sed -n 's\/\^limit=\/\/p'\)/); + for (const own of [/SMALL/, /TRIVIAL/, /has track:show/, /has track:ship/, /has track:ask/, /limit=2/, /classify\(/, /files\.length < 300/]) { + assert.doesNotMatch(guard, own, `в guard осталась своя логика трека: ${own}`); + } +}); + +test('#707: ребейз до ревью — функция и условие шага «Привести ветку к dev» совпадают', async () => { + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + const step = workflow.slice(workflow.indexOf(' - name: Привести ветку к dev\n'), workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n')); + const condition = /\n\s+if ((?:\[ "\$TRACK" = "\w+" \](?: \|\| )?)+); then\n\s+if git merge-tree --write-tree origin\/dev HEAD >\/dev\/null 2>&1; then/.exec(step); + assert.ok(condition, 'условие show/ship перед merge-tree найдено'); + const skipOnClean = new Set([...condition[1].matchAll(/"\$TRACK" = "(\w+)"/g)].map((m) => m[1])); + for (const track of ['ship', 'show', 'ask']) { + assert.equal(!skipOnClean.has(track), rebaseBeforeReview(track, true), `${track}: чистое слияние`); + assert.equal(rebaseBeforeReview(track, false), true, `${track}: конфликт — ребейз`); + assert.equal(rebaseBeforeReview(track, null), true, `${track}: чистота не проверена — как в шаге, ребейз`); + } +}); + +// ---------- #707 AC4: шаг трека, guard и комментарий слияния на настоящем bash ---------- + +const SCRIPTS_DIR = dirname(SCRIPT); +const CONTEXT = { repository: 'o/r', server_url: 'https://github.com', run_id: '42', repository_owner: 'o' }; + +/** + * Тело `run: |` шага так, как его прочтёт YAML: блок кончается на первой + * непустой строке с отступом меньше отступа тела (PROCESS.md §10.4 п.4), и + * обрезанный скрипт тест увидит, а не пропустит. Выражения `github.*` — + * подставлены, как это делает раннер. + */ +function stepRun(workflow, marker) { + const start = workflow.indexOf(marker); + assert.ok(start >= 0, `шаг «${marker.trim()}»`); + const lines = workflow.slice(start).split('\n'); + const from = lines.findIndex((line) => /^\s+run: \|$/.test(line)); + assert.ok(from > 0, 'у шага есть run: |'); + const indent = lines[from].indexOf('run:') + 2; + const body = []; + for (const line of lines.slice(from + 1)) { + if (line.trim() && line.length - line.trimStart().length < indent) break; + body.push(line.slice(indent)); + } + const text = body.join('\n') + .replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => CONTEXT[key]) + .replace(/\$\{\{ needs\.guard\.outputs\.cycle \}\}/g, '1'); + assert.doesNotMatch(text, /\$\{\{/, 'все выражения подставлены'); + return text; +} +const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#696)"\n'; +const GUARD_STEP = ' - id: decide\n'; +const DECIDE_STEP = ' - name: Решение по вердикту\n'; + +test('#707 AC4: изменённые run шага трека, guard и решения по вердикту проходят bash -n', async (t) => { + if (process.platform === 'win32' || spawnSync('bash', ['--version']).status !== 0) { t.skip('bash недоступен'); return; } + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + for (const marker of [TRACK_STEP, GUARD_STEP, DECIDE_STEP]) { + const r = spawnSync('bash', ['-n', '-c', stepRun(workflow, marker)], { encoding: 'utf8' }); + assert.equal(r.status, 0, `${marker.trim()}: ${r.stderr}`); + assert.equal(r.stderr, '', `${marker.trim()}: bash предупреждает (незакрытый heredoc?): ${r.stderr}`); + } + // Свидетель умеет падать: heredoc без закрывающей строки — предупреждение bash. + const broken = spawnSync('bash', ['-n', '-c', 'cat > /dev/null < { + const { readFileSync } = await import('node:fs'); + const workflow = readFileSync(WORKFLOW, 'utf8'); + const run = stepRun(workflow, TRACK_STEP); + assert.equal((run.match(/process-track\.mjs/g) || []).length, 1, 'один вызов скрипта'); + assert.match(run, /gh issue view "\$NUM" --repo "o\/r" --json comments > "\$comments"/, 'JSON комментариев — скрипту'); + assert.match(run, /--comments="\$comments" --owner="\$OWNER"/); + const step = workflow.slice(workflow.indexOf(TRACK_STEP), workflow.indexOf(' - name: Привести ветку к dev\n')); + assert.match(step, /OWNER: \$\{\{ github\.repository_owner \}\}/, 'владелец — владелец репозитория'); + assert.equal((run.match(/gh issue edit/g) || []).length, 1, 'метки меняются в одном месте'); + assert.ok(run.indexOf('gh issue edit') > run.indexOf("grep -qx 'raise=true'"), 'метки — только по флагу повышения'); + // Выходы prepare: заметка — в промпт Review, строка риска — в комментарий слияния. + const prepare = workflow.slice(workflow.indexOf('\n prepare:'), workflow.indexOf(' steps:', workflow.indexOf('\n prepare:'))); + assert.match(prepare, /risk_note: \$\{\{ steps\.track\.outputs\.risk_note \}\}/); + assert.match(prepare, /ship_risk: \$\{\{ steps\.track\.outputs\.ship_risk \}\}/); + const prompt = workflow.slice(workflow.indexOf(' prompt: |\n'), workflow.indexOf(' claude_args: |')); + assert.match(prompt, /\n\s+\$\{\{ needs\.prepare\.outputs\.risk_note \}\}\n/, 'risk_note доходит до промпта Review'); + const decide = workflow.slice(workflow.indexOf(DECIDE_STEP), workflow.indexOf(' - name: dev ушёл вперёд')); + assert.match(decide, /SHIP_RISK: \$\{\{ needs\.prepare\.outputs\.ship_risk \}\}/); + assert.match(decide, /\n\s+\$\{SHIP_RISK\}\n\n\s+\n/, 'строка риска — в комментарии слияния, маркер прежний'); + // `full` — только из меток: гейт берёт его у шага трека, скрипт — у resolveTrack. + const gate = workflow.slice(workflow.indexOf(' - name: Validate на материале\n'), workflow.indexOf(' - name: Validate идёт — раунд продолжит событие\n')); + assert.match(gate, /FULL: \$\{\{ steps\.track\.outputs\.full \}\}/); + assert.equal(decideTrack({ stage: 'code', branch: 'b', labels: ['track:show'], diff: RISKY }).full, false, 'риск полного набора не заказывает'); +}); + +/** Замыкание относительных импортов модуля — то, что шаг получит архивом scripts/ из dev. */ +function importClosure(entry, seen = new Set()) { + if (seen.has(entry)) return seen; + seen.add(entry); + const text = readFileSync(entry, 'utf8'); + for (const m of text.matchAll(/^(?:import|export)[^'"]*from ['"](\.{1,2}\/[^'"]+)['"]/gm)) importClosure(join(dirname(entry), m[1]), seen); + return seen; +} + +// Окружение git без GIT_* родителя и без глобального конфига. +const GIT_ENV = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))), + GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', +}; +const hasTools = () => process.platform !== 'win32' + && ['bash', 'tar', 'git'].every((tool) => spawnSync(tool, ['--version']).status === 0); + +function parseOutput(text) { + const out = {}; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const heredoc = /^([\w-]+)<<(.+)$/.exec(lines[i]); + if (heredoc) { + const end = lines.indexOf(heredoc[2], i + 1); + assert.ok(end > i, `закрывающий разделитель ${heredoc[2]}`); + out[heredoc[1]] = lines.slice(i + 1, end).join('\n'); + i = end; + continue; + } + const kv = /^([\w-]+)=(.*)$/.exec(lines[i]); + if (kv) out[kv[1]] = kv[2]; + } + return out; +} + +/** Песочница: bare origin с dev (scripts/ и src/), ветка задачи, подменённый gh. */ +function trackSandbox(t, { change }) { + const root = mkdtempSync(join(tmpdir(), 'hp-707-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const git = (cwd, ...args) => { + const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + const origin = join(root, 'origin.git'); + const work = join(root, 'work'); + const fake = join(root, 'fake'); + const bin = join(root, 'bin'); + const temp = join(root, 'runner'); + for (const dir of [fake, bin, temp]) mkdirSync(dir); + git(root, 'init', '--bare', '-q', origin); + git(root, 'clone', '-q', origin, work); + git(work, 'checkout', '-q', '-b', 'dev'); + mkdirSync(join(work, 'scripts')); + for (const file of importClosure(SCRIPT)) { + assert.ok(file.startsWith(SCRIPTS_DIR), `${file} вне scripts/`); + writeFileSync(join(work, 'scripts', file.slice(SCRIPTS_DIR.length + 1)), readFileSync(file)); + } + mkdirSync(join(work, 'src', 'styles'), { recursive: true }); + writeFileSync(join(work, 'src', 'pointer-modality.ts'), 'export const a = 1;\nexport const b = 2;\n'); + writeFileSync(join(work, 'src', 'styles', 'plan.styles.ts'), 'export const css = `\n .x { color: red; }\n`;\n'); + git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'base'); + git(work, 'push', '-q', 'origin', 'dev'); + git(work, 'checkout', '-q', '-b', 'issue/7-x'); + change(work); + git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'task'); + git(work, 'push', '-q', 'origin', 'issue/7-x'); + git(work, 'checkout', '-q', '--detach', 'issue/7-x'); + writeFileSync(join(bin, 'gh'), [ + '#!/usr/bin/env bash', + 'printf \'%s\\n\' "$*" >> "$FAKE_DIR/gh-calls"', + 'case "$1 $2" in', + ' "issue view") if [ -f "$FAKE_DIR/comments.json" ]; then cat "$FAKE_DIR/comments.json"; exit 0; fi; echo "gh: API недоступен" >&2; exit 1 ;;', + ' "issue comment") while [ $# -gt 0 ]; do if [ "$1" = "--body-file" ]; then cp "$2" "$FAKE_DIR/comment.md"; fi; shift; done ;;', + ' "issue edit") ;;', + ' *) echo "unexpected gh $*" >&2; exit 97 ;;', + 'esac', + '', + ].join('\n'), { mode: 0o755 }); + const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : ''); + return { + work, fake, + run(script, env) { + for (const name of ['gh-calls', 'comment.md']) rmSync(join(fake, name), { force: true }); + writeFileSync(join(temp, 'output'), ''); + const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { + cwd: work, encoding: 'utf8', + env: { + ...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, FAKE_DIR: fake, GH_TOKEN: 'x', NUM: '7', + GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary'), ...env, + }, + }); + return { + status: r.status, stdout: r.stdout, stderr: r.stderr, + output: parseOutput(read(join(temp, 'output'))), summary: read(join(temp, 'summary')), + calls: read(join(fake, 'gh-calls')).split('\n').filter(Boolean), comment: read(join(fake, 'comment.md')), + }; + }, + comments(list) { + if (list === null) rmSync(join(fake, 'comments.json'), { force: true }); + else writeFileSync(join(fake, 'comments.json'), JSON.stringify({ comments: list })); + }, + }; +} + +const touchChange = (work) => writeFileSync(join(work, 'src', 'pointer-modality.ts'), + "export const a = 1;\nexport const b = 2;\nexport const touch = (e) => e.pointerType === 'touch';\n"); +const trackEnv = (labels, stage = 'code') => ({ STAGE: stage, LABELS: labels, BRANCH: 'issue/7-x', OWNER: 'Matysh' }); + +test('#707 AC4: шаг трека на настоящем bash — ship с риском без подтверждения повышается до show', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const { readFileSync: read } = await import('node:fs'); + const box = trackSandbox(t, { change: touchChange }); + const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP); + box.comments([{ author: { login: 'claude[bot]' }, body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]); + const r = box.run(run, trackEnv('track:ship,S7-code-review')); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^raise=true$/m); + assert.deepEqual(r.calls, [ + 'issue view 7 --repo o/r --json comments', + `issue comment 7 --repo o/r --body-file ${join(dirname(box.work), 'runner', 'track', 'raise.md')}`, + 'issue edit 7 --repo o/r --add-label track:show --remove-label track:ship', + ]); + assert.match(r.comment, /Трек повышен: `track:ship` → `track:show`/); + assert.match(r.comment, /- touch: src\/pointer-modality\.ts:3 · участок pointer-modality, токен pointerType/); + assert.match(r.comment, /\[Прогон\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/42\)/); + assert.equal(r.output.track, 'show'); + assert.equal(r.output.ship, 'false'); + assert.equal(r.output.mutants, 'false'); + assert.equal(r.output.full, 'false'); + assert.equal(r.output.risk, 'touch'); + assert.match(r.output.risk_note, /Трек show держится на «решать нечего»/); + assert.equal(r.output.ship_risk, undefined); + assert.match(r.summary, /трек \*\*show\*\* · основание: повышен конвейером с ship \(риск touch\); было: метка без подтверждения — предложение · риск по участкам: touch/); +}); + +test('#707 AC4: шаг трека на настоящем bash — ship, подтверждённый владельцем, остаётся; строка риска доходит до hp:ship-merge', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const { readFileSync: read } = await import('node:fs'); + const workflow = read(WORKFLOW, 'utf8'); + const box = trackSandbox(t, { change: touchChange }); + box.comments([{ author: { login: 'Matysh' }, body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]); + const r = box.run(stepRun(workflow, TRACK_STEP), trackEnv('track:ship,S7-code-review')); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^raise=false$/m); + assert.deepEqual(r.calls, ['issue view 7 --repo o/r --json comments'], 'ни комментария, ни смены меток'); + assert.equal(r.output.track, 'ship'); + assert.equal(r.output.ship, 'true'); + assert.equal(r.output.risk_note, undefined, 'модель на ship не зовётся'); + assert.match(r.output.ship_risk, /^Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:3/); + assert.match(r.output.basis, /^метка, подтверждённая владельцем \(2026-09-30\)$/); + + // Комментарий слияния ship: шаг «Решение по вердикту» как есть, с этой строкой риска. + const { SHIP_MERGE_MARKER_RE, shipRiskFrom } = await import('../scripts/ship-review.mjs'); + const material = 'a'.repeat(40); + const decide = stepRun(workflow, DECIDE_STEP).replaceAll('/tmp/ship-merge.md', join(box.fake, 'ship-merge.md')); + const merged = box.run(decide, { OUT: '', STAGE: 'code', REUSE: 'false', SHIP: 'true', SHIP_RISK: r.output.ship_risk, MATERIAL: material, VALIDATE_URL: 'https://v' }); + assert.equal(merged.status, 0, merged.stderr); + assert.equal(merged.output.green, 'true'); + assert.equal(SHIP_MERGE_MARKER_RE.exec(merged.comment)?.[1], material, 'маркер слияния по-прежнему находится'); + assert.deepEqual(shipRiskFrom([{ body: merged.comment }]), { + classes: ['touch'], line: r.output.ship_risk.split('\n')[0], + }); + // Без риска комментарий прежний: маркер есть, строки риска нет. + const plain = box.run(decide, { OUT: '', STAGE: 'code', REUSE: 'false', SHIP: 'true', SHIP_RISK: '', MATERIAL: material, VALIDATE_URL: '' }); + assert.equal(SHIP_MERGE_MARKER_RE.exec(plain.comment)?.[1], material); + assert.equal(shipRiskFrom([{ body: plain.comment }]), null); +}); + +test('#707 AC4: шаг трека на настоящем bash — комментарии недоступны, этап spec, show', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const { readFileSync: read } = await import('node:fs'); + const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP); + const box = trackSandbox(t, { change: touchChange }); + box.comments(null); + const unknown = box.run(run, trackEnv('track:ship,S7-code-review')); + assert.equal(unknown.status, 0, unknown.stderr); + assert.match(unknown.stdout, /^raise=true$/m, 'без комментариев подтверждения нет'); + assert.match(unknown.output.basis, /происхождение не установлено \(комментарии недоступны\)/); + box.comments([]); + const spec = box.run(run, trackEnv('track:ship,S4-spec-review', 'spec')); + assert.equal(spec.status, 0, spec.stderr); + assert.deepEqual({ track: spec.output.track, ship: spec.output.ship, raise: /^raise=true$/m.test(spec.stdout), risk: spec.output.risk }, + { track: 'ship', ship: 'false', raise: false, risk: '' }, 'этап spec — прежнее поведение, риск пуст'); + const show = box.run(run, trackEnv('track:show,S7-code-review')); + assert.equal(show.status, 0, show.stderr); + assert.equal(show.output.track, 'show'); + assert.equal(show.calls.length, 1, 'show не трогает меток'); + assert.match(show.output.risk_note, /- touch: src\/pointer-modality\.ts:3/); }); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 753d31e6..94d180c5 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ANCHOR_MARKER, REVIEW_DOC_ALLOWLIST, anchorLiveness, REVIEW_HEADER_LINES, citedMaterialShas, danglingMaterialRefusal, materialAnchorBlock, materialAnchorsFrom, parseSpecList, pathsOutsideAllowlist, reviewDocPushRefusal, withMaterialAnchors, @@ -970,14 +974,80 @@ test('guard перечисляет docs/reviews деревом, а не contents assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/); }); -test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', () => { +/** + * Шаг `decide` job guard как есть — настоящим bash (#707). Подменён только + * `gh`: метки, ветка задачи, compare API; счётчик раундов и трек считают + * настоящие `review-doc-guard.mjs` и `process-track.mjs` из рабочей копии. + */ +function runGuard(t, { labels, compare = null, branch = true }) { + const root = mkdtempSync(join(tmpdir(), 'hp-guard-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const bin = join(root, 'bin'); + mkdirSync(bin); + writeFileSync(join(root, 'labels'), `${labels.join('\n')}\n`); + if (compare) writeFileSync(join(root, 'compare'), `${compare.join('\n')}\n`); + writeFileSync(join(bin, 'gh'), [ + '#!/usr/bin/env bash', + 'printf \'%s\\n\' "$*" >> "$FAKE_DIR/calls"', + 'case "$*" in', + ' "issue view 7 --repo o/r --json labels --jq .labels[].name") cat "$FAKE_DIR/labels" ;;', + ' "issue view 7 --repo o/r --json comments") echo \'{"comments":[]}\' ;;', + ` "api repos/o/r/git/matching-refs/heads/issue/7- --jq .[].ref") ${branch ? "echo refs/heads/issue/7-x" : ':'} ;;`, + ' "api repos/o/r/commits/issue/7-x --jq .commit.committer.date") echo 2026-10-01T00:00:00Z ;;', + ' "api repos/o/r/compare/dev...issue/7-x --jq .files[].filename") [ -f "$FAKE_DIR/compare" ] || exit 1; cat "$FAKE_DIR/compare" ;;', + ' "issue comment"*|"issue edit"*) ;;', + ' *) exit 1 ;;', + 'esac', + '', + ].join('\n'), { mode: 0o755 }); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); + const lines = workflow.slice(workflow.indexOf(' - id: decide\n')).split('\n'); + const from = lines.indexOf(' run: |'); + const body = []; + for (const line of lines.slice(from + 1)) { + if (line.trim() && !/^ {10}/.test(line)) break; + body.push(line.replace(/^ {10}/, '')); + } + const context = { repository: 'o/r', server_url: 'https://github.com', run_id: '42' }; + const script = body.join('\n').replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => context[key]); + const output = join(root, 'output'); + writeFileSync(output, ''); + const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { + cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8', + env: { + ...process.env, PATH: `${bin}:${process.env.PATH}`, FAKE_DIR: root, GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: join(root, 'summary'), GH_TOKEN: 'x', REPO: 'o/r', LABEL: 'S7-code-review', NUM: '7', + }, + }); + const out = Object.fromEntries(readFileSync(output, 'utf8').split('\n').filter((l) => /^\w+=/.test(l)).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])); + return { status: r.status, stdout: r.stdout, stderr: r.stderr, out }; +} + +test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', (t) => { + if (process.platform === 'win32' || spawnSync('bash', ['--version']).status !== 0) { t.skip('bash недоступен'); return; } 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/, - 'явная метка трека главнее признака инфраструктуры'); + // #707: трек и лимит — из process-track.mjs; своей логики трека в guard нет. + assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename' > "\$changed" 2>\/dev\/null \\\n\s+\|\| : > "\$changed"/, + 'признак инфраструктуры — пути диффа ветки против dev; отказ API — пустой список'); + assert.match(guard, /node scripts\/process-track\.mjs limit --labels="\$labels" --files="\$changed"/); + assert.doesNotMatch(guard, /limit=2|SMALL|TRIVIAL/, 'лимит 2 guard сам не ставит'); + const infra = ['scripts/x.mjs', '.github/workflows/y.yml']; + const cases = [ + [{ labels: ['S7-code-review'], compare: infra }, 2, 'инфраструктура без трековой метки — show'], + [{ labels: ['S7-code-review', 'track:ask'], compare: infra }, 4, 'явная метка главнее признака инфраструктуры'], + [{ labels: ['S7-code-review'], compare: ['src/a.ts', ...infra] }, 4, 'продукт — ask'], + [{ labels: ['S7-code-review'], compare: null }, 4, 'compare отказал — инфраструктура не доказана'], + [{ labels: ['S7-code-review'], compare: Array.from({ length: 300 }, (_, i) => `scripts/f${i}.mjs`) }, 4, 'обрезанный ответ compare инфраструктуру не доказывает'], + [{ labels: ['S7-code-review'], branch: false }, 4, 'ветки нет'], + [{ labels: ['S7-code-review', 'small'], compare: ['src/a.ts'] }, 2, 'прежняя метка small — show'], + [{ labels: ['S7-code-review', 'track:ship', 'track:ask'], compare: ['src/a.ts'] }, 4, 'несколько трековых меток — строжайшая'], + ]; + for (const [input, limit, why] of cases) { + const r = runGuard(t, input); + assert.equal(r.status, 0, `${why}: ${r.stderr}`); + assert.equal(r.out.stage, 'code', why); + assert.equal(r.out.limit, String(limit), why); + assert.equal(r.out.labels, input.labels.join(','), `${why}: метки для prepare`); + } }); diff --git a/test/ship-review.test.mjs b/test/ship-review.test.mjs index ccbed11f..838c4a93 100644 --- a/test/ship-review.test.mjs +++ b/test/ship-review.test.mjs @@ -5,8 +5,8 @@ import test from 'node:test'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { - anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath, - shipReviewProblems, specSection, + SHIP_MERGE_MARKER_RE, anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath, + shipReviewProblems, shipRiskFrom, specSection, } from '../scripts/ship-review.mjs'; import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs'; import { archivePlan } from '../scripts/reviews-archive.mjs'; @@ -116,3 +116,25 @@ test('#696 оба пути публикации беты проверяют па assert.ok(check > 0, 'локальная публикация зовёт тот же гейт'); assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже'); }); + +test('#707 AC12: бриф ship печатает строку риска из комментария hp:ship-merge, если она есть', async () => { + const { classifyRisk, shipRiskText } = await import('../scripts/process-track.mjs'); + const risk = classifyRisk("diff --git a/src/pointer-modality.ts b/src/pointer-modality.ts\n--- a/src/pointer-modality.ts\n+++ b/src/pointer-modality.ts\n@@ -2,0 +3 @@\n+ if (e.pointerType === 'touch') return;\n"); + const line = shipRiskText({ risk, confirmed: true }); + const merge = `**Слияние без ревью модели: трек ship.** …\n\n${line}\n\n${MARKER}\n`; + assert.equal(SHIP_MERGE_MARKER_RE.exec(merge)?.[1], sha('a'), 'маркер слияния находится и с новой строкой'); + assert.deepEqual(shipRiskFrom([{ body: 'обсуждение' }, { body: merge }]), { classes: ['touch'], line: line.split('\n')[0] }); + assert.equal(shipRiskFrom([{ body: `Слияние без ревью модели\n\n${MARKER}` }]), null, 'комментарий до #707 — риск не записан'); + assert.equal(shipRiskFrom([{ body: '' }]), null, 'строка риска вне комментария слияния не считается'); + const commits = [{ sha: sha('c'), message: 'fix: x\n\nIssue: #701\nUser-Visible: no' }, { sha: sha('d'), message: 'fix: y\n\nIssue: #702\nUser-Visible: no' }]; + const data = { + 701: { title: 'С риском', body: '## ТЗ\n\nстрока', labels: [], comments: [{ body: merge }] }, + 702: { title: 'Без риска', body: '## ТЗ\n\nстрока', labels: [], comments: [{ body: MARKER }] }, + }; + const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] }); + const brief = renderShipBrief({ tag: 'v1.80.0-beta.1', candidate: sha('e'), base: null, ship }); + const section = (n) => brief.split('\n### ').find((part) => part.startsWith(`#${n} `)) ?? ''; + assert.match(section(701), /Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:3 · участок pointer-modality, токен pointerType/); + assert.doesNotMatch(section(702), /Риск по участкам/); + assert.equal(ship.find((i) => i.number === 702).risk, undefined); +}); diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index bc824da0..ffe802e3 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -1,8 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as packet from '../scripts/task-packet.mjs'; import { branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor, - trackFromLabels, hasTrackLabel, + trackFromLabels, hasTrackLabel, readMergeState, } from '../scripts/task-packet.mjs'; import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs'; @@ -241,3 +246,159 @@ test('r1 #695: инфраструктурная задача с явной ме assert.equal(hasTrackLabel(['bug', 'infra']), false); assert.equal(hasTrackLabel(['small']), true); }); + +// ---------- #707: трек, следующий шаг, риск, проверки, changelog ---------- + +const DIFF = (path, text, line = 3) => `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -${line - 1},0 +${line} @@\n+${text}\n`; +const branchWith = (over = {}) => ({ + name: 'issue/707-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: false, + changedFiles: [], diff: '', commits: [], smokes: null, mergeClean: true, conflicts: [], ...over, +}); +const packetOf = ({ labels = ['S6-in-progress'], comments = [], branch = branchWith(), body = '## ТЗ\n- AC1: x\n' } = {}) => buildPacket({ + issue: { number: 707, title: 'risk', state: 'OPEN', url: 'u', body }, labels, comments, owner: 'Matysh', branch, +}); + +test('#707 AC6: пакет берёт трек, основание, лимит, риск и ребейз из тех же функций, что конвейер', async () => { + const track = await import('../scripts/process-track.mjs'); + for (const name of ['trackOrigin', 'cycleLimit', 'rebaseBeforeReview', 'classifyRisk', 'trackFromLabels', 'hasTrackLabel']) { + assert.equal(packet[name], track[name], name); + } +}); + +test('#707 AC7: пакет — раздел «Трек»: четыре основания, лимит и ребейз в markdown и --json', () => { + const owner = [{ author: 'Matysh', body: 'Трек: show — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]; + const variants = [ + [{ labels: ['S6-in-progress', 'track:show'], comments: owner }, 'show', 'метка, подтверждённая владельцем (2026-09-30)', 2, false], + [{ labels: ['S6-in-progress', 'track:ship'] }, 'ship', 'метка без подтверждения — предложение', 2, false], + [{ labels: ['S6-in-progress', 'small'] }, 'show', 'прежняя метка small → show (§5.1)', 2, false], + [{ labels: ['S6-in-progress'] }, 'ask', 'метки нет: продукт → ask', 4, true], + ]; + for (const [input, track, basis, limit, rebase] of variants) { + const p = packetOf(input); + assert.deepEqual( + { track: p.trackDetail.track, basis: p.trackDetail.basis, limit: p.trackDetail.limit, rebase: p.trackDetail.rebaseBeforeReview }, + { track, basis, limit, rebase }, basis, + ); + const md = renderPacket(p); + assert.match(md, new RegExp(`## Трек\\n- ${track} · основание: ${basis.replace(/[()]/g, '\\$&')}\\n- лимит циклов код-ревью: ${limit} · ребейз до ревью: ${rebase ? 'да' : 'нет'}\\n`)); + assert.equal(JSON.parse(JSON.stringify(p)).trackDetail.basis, basis, '--json несёт те же поля'); + } + const infra = packetOf({ labels: ['infra'], branch: branchWith({ infrastructure: true }), body: '' }); + assert.equal(infra.trackDetail.basis, 'метки нет: инфраструктура → show'); + const two = renderPacket(packetOf({ labels: ['S6-in-progress', 'track:ship', 'track:ask'] })); + assert.match(two, /- внимание: несколько трековых меток \(track:ask, track:ship\) — дефект разметки, действует строжайшая track:ask/); + // Без ветки политика show/ship зависит от чистоты слияния — пакет её не выдумывает. + assert.equal(packetOf({ labels: ['track:show'], branch: null }).trackDetail.rebaseBeforeReview, null); + assert.match(renderPacket(packetOf({ labels: ['track:show'], branch: null })), /ребейз до ревью: только при конфликте с dev/); +}); + +test('#707 AC8: следующий шаг — ребейз только там, где он нужен', () => { + const at = (labels, over) => renderPacket(packetOf({ labels: ['S6-in-progress', ...labels], branch: branchWith(over) })); + assert.doesNotMatch(at(['track:show'], { behind: 0 }), /## Следующий шаг/, 'позади 0 — про ребейз ничего'); + const askClean = at(['track:ask'], { behind: 3, mergeClean: true }); + assert.match(askClean, /## Следующий шаг\n- позади dev на 3, слияние чистое — конвейер сам приведёт ветку к dev до ревью/); + const showClean = at(['track:show'], { behind: 3, mergeClean: true }); + assert.match(showClean, /- позади dev на 3, слияние чистое — ребейз не нужен: один раз при слиянии/); + for (const md of [askClean, showClean]) assert.doesNotMatch(md, /перед S7 ребейз/); + assert.match(at(['track:show'], { behind: 2, mergeClean: false, conflicts: ['src/a.ts', 'docs/b.md'] }), + /- слияние с dev конфликтует: src\/a\.ts, docs\/b\.md — ребейз до S7 \(`node scripts\/rebase-on-dev\.mjs`\)/); + assert.match(at(['track:show'], { behind: 2, mergeClean: null }), /- позади dev на 2: чистота слияния не проверена/); +}); + +test('#707 AC8: чистота слияния — настоящий merge-tree во временном репозитории и отказ merge-tree', (t) => { + if (spawnSync('git', ['--version']).status !== 0) { t.skip('git недоступен'); return; } + const dir = mkdtempSync(join(tmpdir(), 'hp-packet-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const env = { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k))), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }; + const git = (...args) => { + const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd: dir, encoding: 'utf8', env }); + assert.equal(r.status, 0, r.stderr); + return r.stdout.trim(); + }; + git('init', '-q', '-b', 'dev'); + writeFileSync(join(dir, 'a.txt'), 'base\n'); writeFileSync(join(dir, 'b.txt'), 'base\n'); + git('add', '.'); git('commit', '-q', '-m', 'base'); + git('checkout', '-q', '-b', 'task'); + writeFileSync(join(dir, 'a.txt'), 'task\n'); + git('commit', '-q', '-am', 'task'); + git('checkout', '-q', 'dev'); + writeFileSync(join(dir, 'b.txt'), 'dev\n'); + git('commit', '-q', '-am', 'dev moves'); + assert.deepEqual(readMergeState({ cwd: dir, onto: 'dev', ref: 'task' }), { clean: true, conflicts: [] }); + writeFileSync(join(dir, 'a.txt'), 'dev\n'); + git('commit', '-q', '-am', 'dev conflicts'); + const conflict = readMergeState({ cwd: dir, onto: 'dev', ref: 'task' }); + assert.deepEqual(conflict, { clean: false, conflicts: ['a.txt'] }); + assert.match(renderPacket(packetOf({ labels: ['S6-in-progress', 'track:show'], branch: branchWith({ behind: 2, mergeClean: conflict.clean, conflicts: conflict.conflicts }) })), + /слияние с dev конфликтует: a\.txt — ребейз до S7/); + // git < 2.38: `--write-tree` неизвестен — usage и код 129; это не «конфликта нет». + const old = () => ({ status: 129, stdout: '', stderr: 'usage: git merge-tree ' }); + assert.deepEqual(readMergeState({ cwd: dir, onto: 'dev', ref: 'task', run: old }), { clean: null, conflicts: [] }); + assert.deepEqual(readMergeState({ cwd: dir, run: () => ({ error: new Error('ENOENT') }) }), { clean: null, conflicts: [] }); +}); + +const TOUCH = DIFF('src/pointer-modality.ts', " if (e.pointerType === 'touch') return;", 7); +const RENDER = DIFF('src/render/paper-scene.ts', ' const scale = 2;'); +const GEOMETRY = DIFF('src/wall-merge.ts', ' const merged = a + b;'); +const STYLE = DIFF('src/styles/plan.styles.ts', ' gap: 4px;'); + +test('#707 AC9: риск по участкам и следствие по треку', () => { + const risk = (labels, comments = []) => packetOf({ labels: ['S6-in-progress', ...labels], comments, branch: branchWith({ diff: TOUCH }) }); + const confirmed = [{ author: 'Matysh', body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]; + assert.equal(risk(['track:ship']).risk.consequence, 'конвейер повысит до show при S7'); + assert.equal(risk(['track:ship'], confirmed).risk.consequence, 'не повысит; риск прочтёт пакетное ревью'); + assert.equal(risk(['track:show']).risk.consequence, 'ревьюер спросит, где поведение зафиксировано; нет ссылки — повысить до ask до S7 (§5)'); + assert.equal(risk(['track:ask']).risk.consequence, 'справочно'); + const md = renderPacket(risk(['track:ship'])); + assert.match(md, /## Риск по участкам\n- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType\n- следствие: конвейер повысит до show при S7\n/); + assert.match(renderPacket(packetOf({ branch: null })), /## Риск по участкам\n- не посчитан: ветки нет\n/); + assert.doesNotMatch(renderPacket(packetOf({ branch: branchWith({ diff: DIFF('scripts/x.mjs', 'pointerdown') }) })), /## Риск по участкам/, + 'пустой раздел не печатается'); + assert.equal(JSON.parse(JSON.stringify(risk(['track:ship']))).risk.classes[0], 'touch', '--json несёт риск'); +}); + +test('#707 AC10: обязательные проверки с основаниями', () => { + const checksOf = (over, labels = []) => packetOf({ labels: ['S6-in-progress', ...labels], branch: branchWith(over) }).checks; + const commands = (list) => list.map((c) => c.command); + const base = checksOf({}); + assert.deepEqual(base, [{ command: '`npm run gate:small`', reason: 'всегда (§8)' }], 'gate:small — всегда'); + assert.deepEqual(packetOf({ branch: null }).checks.map((c) => c.command), ['`npm run gate:small`']); + const smokes = checksOf({ smokes: { direct: [{ smoke: 'smoke_a.mjs', symbols: ['_wallA'] }], registered: [{ smoke: 'smoke_b.mjs', symbols: ['_b'] }], visualMinimum: ['smoke_modes.mjs'] } }); + assert.deepEqual(smokes.slice(1).map((c) => `${c.command} · ${c.reason}`), [ + '`node demo/smoke_a.mjs` · smoke-select: прямое совпадение (_wallA)', + '`node demo/smoke_b.mjs` · smoke-select: зарегистрированная связь (_b)', + '`npm run gate:small -- --smokes` · smoke-select: визуальный минимум — связь диффа со смоками не доказана (#690): smoke_modes', + ]); + assert.ok(commands(checksOf({ diff: GEOMETRY })).includes('`npm run invariants -- --config <экспорт>`'), 'invariants при geometry'); + assert.ok(!commands(checksOf({ diff: TOUCH })).some((c) => c.includes('invariants')), 'без geometry — нет'); + assert.ok(commands(checksOf({ changedFiles: ['custom_components/houseplan/store.py'] })).includes('`python -m pytest tests_backend -q`')); + assert.ok(!commands(checksOf({ changedFiles: ['src/a.ts', 'tests_backend/test_x.py'] })).some((c) => c.includes('pytest')), 'pytest — только при custom_components/**/*.py'); + for (const mirror of ['src/junction-limits.ts', 'custom_components/houseplan/junction_limits.py', 'test/fixtures/junction-limits-parity.json']) { + assert.ok(commands(checksOf({ changedFiles: [mirror] })).some((c) => c.includes('junction_parity.py')), mirror); + } + assert.ok(!commands(checksOf({ changedFiles: ['src/wall-merge.ts'] })).some((c) => c.includes('junction_parity'))); + const golden = (over, labels) => checksOf(over, labels).find((c) => c.command.includes('ci:golden')); + assert.match(golden({ diff: RENDER }).reason, /^рекомендовано, если сдвиг кадров намерен/, 'ci:golden — при visual/render'); + assert.match(golden({ diff: RENDER }, ['ci:golden']).reason, /^стоит/); + assert.equal(golden({ diff: STYLE }), undefined, 'visual/ui — без ci:golden'); + assert.equal(golden({ diff: DIFF('src/render/paper-scene.ts', ' // stroke-width: 2') }), undefined, 'только комментарии — без ci:golden'); + assert.equal(golden({ diff: DIFF('test/render.test.mjs', " stroke-width='2'"), changedFiles: ['test/render.test.mjs'] }), undefined, 'только тесты — без ci:golden'); + assert.match(renderPacket(packetOf({ branch: branchWith({ diff: GEOMETRY }) })), /## Обязательные проверки\n- `npm run gate:small` · всегда \(§8\)\n- `npm run invariants -- --config <экспорт>` · риск geometry/); +}); + +test('#707 AC11: changelog и визуальное свидетельство', () => { + const commits = [ + { message: 'fix: a (#707)\n\nIssue: #707\nUser-Visible: yes\n' }, + { message: 'test: b (#707)\n\nIssue: #707\nUser-Visible: no\n' }, + { message: 'docs: c' }, + ]; + const p = packetOf({ branch: branchWith({ commits, changedFiles: ['src/a.ts', 'docs/CHANGELOG.md'] }) }); + assert.deepEqual(p.changelog, { yes: 1, no: 1, missing: ['docs/CHANGELOG.ru.md'], visualEvidence: false }); + const md = renderPacket(p); + assert.match(md, /## Changelog и визуальное свидетельство\n- коммитов ветки: `User-Visible: yes` — 1, `User-Visible: no` — 1\n- не хватает docs\/CHANGELOG\.ru\.md/); + assert.deepEqual(packetOf({ branch: branchWith({ commits, changedFiles: ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'] }) }).changelog.missing, []); + assert.deepEqual(packetOf({ branch: branchWith({ commits: [commits[1]] }) }).changelog.missing, [], 'User-Visible: no changelog не требует'); + const visual = renderPacket(packetOf({ branch: branchWith({ diff: RENDER }) })); + assert.match(visual, /дефект растра или резкости требует свидетеля, красного на старом коде, и подтверждения владельца в GPU-браузере \(§7\.1\)/); + assert.doesNotMatch(renderPacket(packetOf({ branch: branchWith({ diff: STYLE }) })), /## Changelog/, 'нет коммитов и нет визуала — раздел не печатается'); +});