diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index 6c6086af..492ea600 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -339,6 +339,10 @@ jobs: # для комментария слияния ship (пакетное ревью, §11.7). risk_note: ${{ steps.track.outputs.risk_note }} ship_risk: ${{ steps.track.outputs.ship_risk }} + # #726: подтверждение трека владельцем — решению по вердикту; заметка + # маршрута (`route`/`criterion`) — в промпт Review. + confirmed: ${{ steps.track.outputs.confirmed }} + route_note: ${{ steps.track.outputs.route_note }} steps: - name: Начать измерение стадии id: clock @@ -1171,6 +1175,8 @@ jobs: ${{ needs.prepare.outputs.risk_note }} + ${{ needs.prepare.outputs.route_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): их канон — @@ -1322,7 +1328,7 @@ jobs: claude_args: | --max-turns 150 --allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read - --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}' + --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"},"route":{"type":"string","enum":["fix","reclassify"]},"criterion":{"type":"string"}},"required":["verdict","high","medium","summary","route"]}' - name: Запечатать результат модели id: result @@ -1495,6 +1501,9 @@ jobs: run: | verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true) high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true) + # #726: маршрут вердикта — хвостом строки якоря; вне словаря скрипт его не пишет. + route=$(printf '%s' "$OUT" | jq -r '.route // empty' 2>/dev/null || true) + criterion=$(printf '%s' "$OUT" | jq -r '.criterion // empty' 2>/dev/null || true) # Ветки задачи может не быть: у задач, размеченных до появления # конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча # выходил с нулём, и разбор ревью терялся — оставался только вердикт @@ -1551,7 +1560,8 @@ jobs: --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ --issue-body="$MATERIAL_ISSUE_BODY" \ - --verdict="$verdict" --high="$high" + --verdict="$verdict" --high="$high" \ + --route="$route" --criterion="$criterion" else echo "::warning::$SOURCE не найден — документа для публикации нет" fi @@ -1707,7 +1717,26 @@ jobs: VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} + # #726: маршрут вердикта — трек и его подтверждение владельцем (шаг + # трека), бюджет этапа (guard), ветка материала с документами ревью. + TRACK: ${{ needs.prepare.outputs.track }} + CONFIRMED: ${{ needs.prepare.outputs.confirmed }} + LABELS: ${{ needs.guard.outputs.labels }} + SPENT: ${{ needs.guard.outputs.spent }} + LIMIT: ${{ needs.guard.outputs.limit }} + CYCLE: ${{ needs.guard.outputs.cycle }} + BRANCH: ${{ needs.prepare.outputs.branch }} run: | + # ship и повторно применённый зелёный (#499) модель не вызывали: + # маршрута у них нет, решение прежнее — зелёный двигает вперёд. + green_forward() { + green=true + case "$STAGE" in + spec) from=S4-spec-review; to=S5-ready ;; + code) from=S7-code-review; to=S8-merged ;; + esac + } + green=""; from=""; to="" # Повторно применимый зелёный вердикт (#499) главнее ship: код уже # прочитан моделью, и пакетному ревью перечитывать его незачем. if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then @@ -1725,6 +1754,7 @@ jobs: EOF gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/ship-merge.md + green_forward elif [ "$REUSE" = "true" ]; then # Модель не вызывалась: вердикт — записанный конвейером зелёный # прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499). @@ -1734,28 +1764,51 @@ jobs: "Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC} Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор." + green_forward else - verdict=$(echo "$OUT" | jq -r '.verdict') - high=$(echo "$OUT" | jq -r '.high') + # #726: вердикт модели решает скрипт трека командой route — ОДИН вызов: + # статус, метки трека, `blocked` и `review-4`, тело комментария + # файлом и строка сводки. Вперёд двигает только зелёный (§7.2); + # жёлтый и красный — цикл и возврат автору, на код-ревью show с + # `route: reclassify` — повышение до ask или вопрос владельцу. + # Bash только исполняет: комментарий — из файла скрипта, метки — + # по его выходу. Скрипт — из dev, как у шага трека: рабочая копия + # здесь — ветка задачи после публикации, и ветка show/ship, + # отставшая от dev, его может ещё не нести. Метки — текущие: шаг + # трека мог в этом же прогоне сменить ship на show. + git fetch -q origin dev + tools="$RUNNER_TEMP/route-tools" + rm -rf "$tools" && mkdir -p "$tools" + git archive origin/dev scripts | tar -x -C "$tools" + printf '%s' "$OUT" > "$RUNNER_TEMP/route-verdict.json" + labels=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels \ + --jq '[.labels[].name] | join(",")') || labels="$LABELS" + route=$(node "$tools/scripts/process-track.mjs" route --stage="$STAGE" --track="$TRACK" \ + --confirmed="$CONFIRMED" --labels="$labels" --verdict="$RUNNER_TEMP/route-verdict.json" \ + --spent="$SPENT" --limit="$LIMIT" --num="$NUM" --cycle="$CYCLE" --branch="$BRANCH" \ + --out="$RUNNER_TEMP/route" \ + --run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}") + printf '%s\n' "$route" + field() { printf '%s\n' "$route" | sed -n "s/^$1=//p"; } + verdict=$(field verdict); high=$(field high) + green=$(field green); from=$(field from); to=$(field to) + add=$(field add_labels); remove=$(field remove_labels); comment=$(field comment) + if [ -n "$comment" ]; then + gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$comment" + fi + edit=() + if [ -n "$add" ]; then edit+=(--add-label "$add"); fi + if [ -n "$remove" ]; then edit+=(--remove-label "$remove"); fi + if [ "${#edit[@]}" -gt 0 ]; then + gh issue edit "$NUM" --repo "${{ github.repository }}" "${edit[@]}" + fi fi echo "вердикт: $verdict, High: $high" - - # Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают - # автору: на прогоне #111 жёлтый означал, что AC описывает неверное - # изменение контракта — реализовать такое ТЗ значит сделать ошибку - # по инструкции. Оба считаются циклом. - if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then - green=true - case "$STAGE" in - spec) from=S4-spec-review; to=S5-ready ;; - code) from=S7-code-review; to=S8-merged ;; - esac - else - green=false - case "$STAGE" in - spec) from=S4-spec-review; to=S3-spec ;; - code) from=S7-code-review; to=S6-in-progress ;; - esac + # Перехода нет — сбой шага, а не зелёный: метка остаётся, зовётся владелец. + case "$green" in true|false) ;; *) echo "::error::решение по вердикту не дало исхода (green=$green)"; exit 1 ;; esac + if [ -z "$from" ] || [ -z "$to" ]; then + echo "::error::решение по вердикту не дало перехода ($from -> $to)" + exit 1 fi echo "green=$green" >> "$GITHUB_OUTPUT" echo "from=$from" >> "$GITHUB_OUTPUT" diff --git a/PROCESS.md b/PROCESS.md index c7a089f4..c1b1b999 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -539,6 +539,16 @@ patch-id кандидата слияния: вердикт к работе за - Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже лимита, конвейер сообщает пересчёт, но метку не трогает. +- **Бюджет этапа один на все треки задачи** (#726). Повышение трека меняет + лимит, а не счёт: блокирующие вердикты прежних заходов остаются в бюджете + этапа, и после `show` → `ask` код-ревью продолжает тот же счёт с лимитом 4 — + на всех треках вместе не больше 4 циклов. Ревью ТЗ между ними — свой этап и + свой бюджет. +- **Вердикт, исчерпавший бюджет, сразу ставит `review-4`** (#726): шаг решения + по вердикту возвращает задачу (`S6-in-progress` или `S3-spec`) с меткой и + комментарием «Лимит циклов ревью исчерпан» — счёт, учтённые вердикты и + варианты ниже. Прежде об исчерпании говорил только следующий `S7`, после + правки, которую уже никто не прочтёт; его проверка осталась страховкой. - **Исчерпание лимита — не «пятая попытка», а разбор.** Задача уходит владельцу, решение одно из трёх: 1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с @@ -618,12 +628,12 @@ patch-id кандидата слияния: вердикт к работе за остаётся, риск уходит в пакетное ревью (§11.7). `visual` ship не повышает: CSS-мелочь — законный ship. Выход за рамки повышает трек и при подтверждённом `ship`, как прежде. -- На `show` риск — вопрос ревьюеру, маршрут не меняется: по каждому классу - ревьюер называет документ или AC, где поведение уже зафиксировано; не нашёл — - Medium «решать есть что — нужен `track:ask`» с названным критерием. `show`, - подтверждённый владельцем, ревьюер не повышает: вопрос уходит владельцу с - вариантом по умолчанию «повысить до `ask`». На `ask` ревьюер сверяет, что - каждый класс покрыт AC ТЗ. +- На `show` риск — вопрос ревьюеру, сам по себе трек он не меняет: по каждому + классу ревьюер называет документ или AC, где поведение уже зафиксировано; не + нашёл — вердикт с `route: reclassify` и названным критерием (#726, ниже). + `show`, подтверждённый владельцем, конвейер не повышает: `blocked` и вопрос + владельцу с вариантом по умолчанию «повысить до `ask`». На `ask` ревьюер + сверяет, что каждый класс покрыт AC ТЗ. - Автор видит те же классы и их следствие в пакете задачи (`node scripts/task-packet.mjs --issue NN`) до `S7`: пакет и конвейер читают одно правило. @@ -651,6 +661,18 @@ patch-id кандидата слияния: вердикт к работе за рискованный участок повышает `ship` и требует от ревью `show` ссылки на зафиксированное поведение. +На код-ревью `show` повышение исполняет конвейер (#726). Ревьюер возвращает не +зелёный вердикт с `route: reclassify` и `criterion` — идентификатором +невыполненного пункта подсказки выше: `complexity`, `surfaces`, `migration`, +`ux-contract`, `perf-touch`, `undocumented`. Шаг решения по вердикту ставит +`track:ask`, снимает `track:show` и переводит задачу в `S3-spec` комментарием: +критерий словами, документы код-ревью, бюджет. Код остаётся в ветке, полное ТЗ +пишется в теле issue, код класса A не пушится до `S5`; бюджет код-ревью не +обнуляется (§4). Подтверждённый владельцем `show` конвейер не повышает: задача +получает `blocked` и вопрос владельцу с вариантом по умолчанию «повысить до +`ask`». `reclassify` без критерия из списка, на `ask` или на ревью ТЗ — +обычный возврат с пометкой в комментарии. + **Что не меняется ни на одном треке:** issue и правило №1 (§1), трейлеры коммитов, changelog для видимого изменения, зелёный Validate на точном SHA тега беты (§8), гейты стабильного релиза. Качество держится на `dev` и на кандидате @@ -791,6 +813,13 @@ issue #NN Заход — номер прогона ревью, K — израсходованный бюджет §4: зелёные вердикты его не тратят, поэтому заход и K расходятся, #227) - **Закрытие:** `Выпущено в <тег беты> · CI: <ссылка> · Changelog: <ссылка>` +- **Маршрут вердикта** (пишет конвейер, #726): первой строкой «**Ревью show: + решать есть что — трек повышен до `track:ask`.**» либо «**Ревью show: решать + есть что — вопрос владельцу.**», последней — машинная + ``. Исчерпание + бюджета — первой строкой `Лимит циклов ревью исчерпан`, дальше счёт, + учтённые вердикты и варианты §4. Маршрут, не применённый к вердикту, — + строка «маршрут reclassify не применён: <причина>». **Вперёд двигает только зелёный вердикт.** Жёлтый и красный возвращают автору; разница между ними содержательна для человека, но не для маршрута. Первая @@ -1195,6 +1224,7 @@ SHA. Manifest публикуется и входит в `SHA256SUMS`; свежа ``` S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-spec S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress + (show, route: reclassify → S3-spec, #726) ``` Текущая техническая реализация независимого ревьюера — @@ -1266,6 +1296,23 @@ limit`) и своей логики трека не держит; ответ comp больше инфраструктуру не доказывает — `ask`, лимит 4. Этап `spec`, отсутствие ветки и инфраструктурный дифф дают пустой риск и прежнее поведение. +**Маршрут вердикта** (#726). Структурный вердикт модели — `verdict`, `high`, +`medium`, `summary`, `route` (`fix` | `reclassify`, обязательное в схеме) и +необязательный `criterion`. Граница доверия (`scripts/review-result-gate.mjs`) +читает отсутствующий `route` как `fix`, отвергает значение вне словаря и +`reclassify` при зелёном вердикте: результат противоречив, модель не +перезапускается. Смысл `criterion` судит шаг «Решение по вердикту»: **один +вызов** `process-track.mjs route` по вердикту, треку и его подтверждению +владельцем (выход шага трека), бюджету guard и текущим меткам; статус, метки и +тело комментария (файлом) — из выхода скрипта. Метки трека, `blocked` и +`review-4` ставит `gh issue edit` по этому выходу, статус — `status-label.mjs`, +как прежде. Шаги без модели (`ship` в рамках, повторно применённый зелёный) +скрипт не зовут. Заметку маршрута в промпт даёт шаг трека: на код-ревью `show` — +поля и список критериев §5, иначе `route: fix`. Документ ревью несёт маршрут +хвостом строки якоря — «Вердикт конвейера: `yellow` · High 0 · маршрут +`reclassify` (критерий `undocumented`)», — сводка прогона печатает маршрут и +бюджет после вердикта. + | | `ship` | `show` | `ask` | |---|---|---|---| | Validate на материале | лёгкий; годится завершённый push-прогон на этом SHA | лёгкий, как у `ship` | лёгкий, как у `ship` (#709) | diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index bfdac207..8358e249 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -60,6 +60,12 @@ `S7`; на `show` ревьюер спросит, где поведение зафиксировано, — нет ссылки, повысь трек до `ask` до `S7`. Классы и следствие печатает пакет задачи ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Вердикт код-ревью `show` с `route: reclassify` конвейер исполняет сам: + `track:ask`, `S3-spec` и комментарий с критерием §5 (#726). Дальше — полное ТЗ + по §7.1 в теле issue под `## ТЗ` и ревью ТЗ; код остаётся в ветке, код класса A + не пушить до `S5`. Бюджет код-ревью не обнуляется, лимит — 4. Подтверждённый + владельцем `show` получает `blocked` и вопрос владельцу + ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). - `track:show`: `S2-analysis` → `S5-ready`, до трёх AC автор пишет в теле issue до перехода; ревью ТЗ нет, лимит код-ревью 2. Уместен, когда всё сразу: сложность и риск ≤ 3, одна поверхность, нет миграции, нового @@ -102,8 +108,9 @@ открытых продуктовых вопросов. На `ship` и `show` пункты DoR закрываются словом «нет» ([§2.5](../../PROCESS.md#25-готово-к-разработке-dor)). - Лимит — 4 цикла ревью, на `track:show` 2 цикла код-ревью; зелёный вердикт цикла - не тратит; исчерпание — решение владельца: разделить, отклонить, арбитраж - ([§4](../../PROCESS.md#4-лимит-циклов-ревью-4)). + не тратит; бюджет этапа один на все треки задачи; вердикт, исчерпавший + бюджет, сразу ставит `review-4`; исчерпание — решение владельца: разделить, + отклонить, арбитраж ([§4](../../PROCESS.md#4-лимит-циклов-ревью-4)). ## Реализация (`S6-in-progress`) diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index de4ad305..51f595cb 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -125,7 +125,8 @@ - Вперёд двигает только зелёный вердикт; жёлтый и красный возвращают автору ([§7.2](../../PROCESS.md#72-шаблоны-комментариев)). - Зелёный вердикт цикла не образует; лимит — 4 цикла, на `track:show` 2; - бюджет считается по этапу + бюджет считается по этапу и один на все треки задачи; вердикт, исчерпавший + бюджет, сразу ставит `review-4` ([§4](../../PROCESS.md#4-лимит-циклов-ревью-4), [§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). - Запрещено: Medium-находки, оставленные как TODO в документе ревью; @@ -146,11 +147,18 @@ ветка как есть, кандидат проверит Validate при слиянии ([§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). - Промпт несёт риск по изменённым участкам: по каждому классу назови документ - или AC, где поведение уже зафиксировано; не нашёл — Medium «решать есть что — - нужен `track:ask`» с названным критерием. `show`, подтверждённый владельцем, - не повышать — вопрос владельцу, вариант по умолчанию «повысить до `ask`»; на - `ask` — сверить, что каждый класс покрыт AC ТЗ - ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). + или AC, где поведение уже зафиксировано; не нашёл — вердикт с + `route: reclassify` и названным критерием. `show`, подтверждённый + владельцем, конвейер не повышает — `blocked` и вопрос владельцу, вариант по + умолчанию «повысить до `ask`»; на `ask` — сверить, что каждый класс покрыт + AC ТЗ ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца)). +- Поле `route` вердикта (#726): на код-ревью `show` — `reclassify` и + `criterion` (`complexity`, `surfaces`, `migration`, `ux-contract`, + `perf-touch`, `undocumented`), если задача не проходит критерий §5; иначе и + на прочих этапах и треках — `fix`. `reclassify` — только не зелёный вердикт; + трек и метки меняет конвейер, не ревьюер + ([§5](../../PROCESS.md#5-треки-ship-show-ask--метка-владельца), + [§10.4](../../PROCESS.md#104-событийный-конвейер-метка-как-триггер)). - Визуальный риск в пути отрисовки плана без `ci:golden`: если задача меняет вид, нужен `ci:golden`; иначе — запись в «чего не проверял» ([§5.1](../../PROCESS.md#51-метки-тяжёлых-проверок-и-прежние-метки)). diff --git a/scripts/process-track.mjs b/scripts/process-track.mjs index 0ec11ad4..75fc6b19 100644 --- a/scripts/process-track.mjs +++ b/scripts/process-track.mjs @@ -5,6 +5,8 @@ * * 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 route --stage=code|spec --track= --confirmed=true|false --labels="a,b" \ + * --verdict= --spent=N --limit=N --num=NN --cycle=N --branch=<имя> --out= [--ref=HEAD --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= @@ -15,7 +17,15 @@ * слияния ship. Печатает `track=`, `mutants=`, `full=`, `ship=`, `raise=`, * `basis=`, `risk=`; те же поля и многострочные `risk_note`/`ship_risk` пишет в * `$GITHUB_OUTPUT`; при `raise=true` кладёт тело комментария в `/raise.md`. - * Bash шага только исполняет: логики трека в нём нет. + * Bash шага только исполняет: логики трека в нём нет. С #726 там же + * `confirmed=` и многострочная `route_note` — заметка маршрута для промпта. + * + * `route` — шаг «Решение по вердикту» (#726): ОДИН вызов на заход модели решает + * статус, метки трека, `blocked` и `review-4` по вердикту, треку, подтверждению + * владельца и бюджету guard. Печатает `kind=`, `green=`, `from=`, `to=`, + * `add_labels=`, `remove_labels=`, `exhausted=`, `spent_after=`, `limit_after=`; + * тело комментария кладёт в `/comment.md`, строку сводки — в + * `$GITHUB_STEP_SUMMARY`. Bash шага меняет метки только по этому выходу. * * `limit` — job `guard`: трек и лимит циклов по меткам и списку файлов из * compare API; ответ на 300 файлов и больше инфраструктуру не доказывает. @@ -28,11 +38,13 @@ */ import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { isMainModule } from './spawn-portable.mjs'; import { classify } from './change-classes.mjs'; import { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES } from './change-risk.mjs'; +import { verdictProblems, verdictRoute } from './review-result-gate.mjs'; +import { blockingFromDocs } from './review-doc-guard.mjs'; // Классификатор риска живёт рядом (ТЗ #707 §10 п.5); пакет и конвейер берут его отсюда. export { classifyRisk, emptyRisk, riskClassLine, RISK_CLASSES, RAISING_CLASSES }; @@ -230,10 +242,49 @@ export function parseNameStatus(text = '') { }); } +/** + * Критерии §5 («Подсказка аналитику») для `show` — идентификатор поля + * `criterion` вердикта → пункт канона дословно (К1 #726). Порядок — как в §5. + */ +export const SHOW_CRITERIA = Object.freeze({ + complexity: 'сложность и риск ≤ 3', + surfaces: 'одна поверхность (один диалог, один модуль, один эндпоинт)', + migration: 'нет миграции конфига и новых compatibility-полей', + 'ux-contract': 'нет нового UX-контракта — меняется поведение в рамках уже описанного', + 'perf-touch': 'нет влияния на производительность и на touch-контракт', + undocumented: 'ожидаемое поведение уже зафиксировано — в `docs/USER-GUIDE.ru.md`, в каноническом документе подсистемы либо однозначно в самом отчёте', +}); + +/** Критерий из таблицы §5 — только строкой и только своим ключом. */ +export const isShowCriterion = (criterion) => typeof criterion === 'string' && Object.hasOwn(SHOW_CRITERIA, criterion); + +/** + * Значение модели в тексте конвейера: только `[A-Za-z0-9_-]`, не длиннее 40 — без + * разметки, обратных кавычек и `-->`. Поле недоверенное, его смысл судит таблица. + */ +export const safeToken = (value) => String(value ?? '').replace(/[^A-Za-z0-9_-]/g, '?').slice(0, 40); + +/** + * Заметка маршрута для промпта ревью (К4 #726). Код-ревью `show` — поля + * `route`/`criterion` и список критериев §5; любой другой этап и трек — + * `route: fix`. Одна строка в промпте (`route_note`), текст — здесь, под тестом. + */ +export function routeNote({ stage = 'code', track = 'ask', confirmed = false } = {}) { + if (stage !== 'code' || track !== 'show') return '**Маршрут вердикта (#726):** `route: fix`.'; + const after = confirmed + ? 'трек show подтверждён владельцем — конвейер его не повысит, а поставит `blocked` и задаст владельцу вопрос' + : 'конвейер сам переведёт задачу в `track:ask` и `S3-spec`, код останется в ветке'; + return [ + `**Маршрут вердикта (#726).** \`route: reclassify\` и \`criterion\` — если задача не проходит критерий §5 из списка ниже; иначе \`route: fix\`. \`reclassify\` — не зелёный вердикт: ${after}.`, + ...Object.entries(SHOW_CRITERIA).map(([id, text]) => `- \`${id}\` — ${text}`), + ].join('\n'); +} + /** * Заметка риска для промпта ревью `show`/`ask` (К3 #707), не длиннее * `RISK_NOTE_LINE_LIMIT` строк; пустой риск — пустая строка. Риск — вопрос - * ревьюеру, маршрут не меняется: автоматического show → ask нет. + * ревьюеру: трек по риску не меняется, а по вердикту ревьюера — да + * (`route: reclassify`, #726). */ export function riskNote({ track, confirmed = false, risk, labels = [] } = {}) { if (!risk?.classes?.length) return ''; @@ -241,9 +292,9 @@ export function riskNote({ track, confirmed = false, risk, labels = [] } = {}) { 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.'); + lines.push('**Риск по изменённым участкам (#707).** Трек show держится на «решать нечего» (PROCESS.md §5). По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; не нашёл — `route: reclassify` с названным критерием §5 (#726).'); } else if (track === 'show') { - lines.push('**Риск по изменённым участкам (#707).** Трек show подтверждён владельцем. По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; трек не повышать: не нашёл — вопрос владельцу в комментарии, вариант по умолчанию «повысить до ask».'); + lines.push('**Риск по изменённым участкам (#707).** Трек show подтверждён владельцем. По каждому классу ниже назови документ или AC, где поведение уже зафиксировано; трек не повышать: не нашёл — `route: reclassify` с критерием, и конвейер задаст вопрос владельцу, вариант по умолчанию «повысить до ask» (#726).'); } else { lines.push('**Риск по изменённым участкам (#707).** Трек ask: сверь, что каждый класс ниже покрыт AC ТЗ.'); } @@ -319,10 +370,177 @@ export function decideTrack({ 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 }) : '', + // #726: на ship модель не зовётся — заметка маршрута не нужна. + routeNote: ship ? '' : routeNote({ stage, track, confirmed }), shipRisk: ship ? shipRiskText({ risk, confirmed: origin.confirmed }) : '', }; } +const STATUS_FROM = { spec: 'S4-spec-review', code: 'S7-code-review' }; +const STATUS_GREEN = { spec: 'S5-ready', code: 'S8-merged' }; +const STATUS_BACK = { spec: 'S3-spec', code: 'S6-in-progress' }; +const nonNegative = (value, fallback) => { + const n = Number(value); + return value !== '' && value !== null && value !== undefined && Number.isInteger(n) && n >= 0 ? n : fallback; +}; + +/** + * Решение по вердикту модели (К2 #726) — чистая функция; метки меняет только + * bash шага «Решение по вердикту», по этому выходу. + * + * Вперёд двигает только зелёный вердикт с High 0 (§7.2). Не зелёный — цикл + * (§4): возврат автору, `fix`. Маршрут `reclassify` применяется только на + * код-ревью `show` с критерием из `SHOW_CRITERIA`: без подтверждения + * владельца — `track:ask` и `S3-spec`, с подтверждением — `blocked` и вопрос + * владельцу (трек не меняется). Иначе — `fix` с `note`. + * + * Исчерпание (К3): `spentAfter = spent + 1`, лимит — трека после маршрута + * (`cycleLimit('ask')` у `reclassify`, иначе `limit` guard). Вердикт, + * исчерпавший бюджет, сам ставит `review-4`; статус двигается по таблице. + * Бюджет этапа один на все треки: счёт не обнуляется, меняется только лимит. + * + * `labels` — текущие метки issue, если известны: тогда уже стоящие метки не + * добавляются, а снимается только то, что стоит. + */ +export function reviewRoute({ + stage = 'code', track = 'ask', confirmed = false, verdict = '', high = 0, route = 'fix', criterion = '', + spent = 0, limit, labels = null, +} = {}) { + const st = stage === 'spec' ? 'spec' : 'code'; + const spentBefore = nonNegative(spent, 0); + const limitBefore = nonNegative(limit, cycleLimit(track)); + const known = Array.isArray(labels); + const base = { + from: STATUS_FROM[st], route: route === 'reclassify' ? 'reclassify' : 'fix', + criterion: isShowCriterion(criterion) ? criterion : '', + }; + if (verdict === 'green' && Number(high) === 0) { + return { + ...base, green: true, to: STATUS_GREEN[st], addLabels: [], removeLabels: [], exhausted: false, kind: 'green', + note: '', track, spentAfter: spentBefore, limitAfter: limitBefore, + }; + } + let kind = 'fix'; let to = STATUS_BACK[st]; let note = ''; let trackAfter = track; + const add = []; const remove = []; + if (route === 'reclassify') { + let why = ''; + if (st !== 'code') why = 'этап `spec` — трек по вердикту меняется только на код-ревью'; + else if (track !== 'show') why = `трек \`${safeToken(track)}\` — повышение по вердикту только с \`show\``; + else if (!isShowCriterion(criterion)) { + why = criterion === '' || criterion == null + ? 'критерий §5 не назван' + : `критерий \`${safeToken(typeof criterion === 'string' ? criterion : JSON.stringify(criterion))}\` не из списка §5 (${Object.keys(SHOW_CRITERIA).join(', ')})`; + } + if (why) { + note = `маршрут reclassify не применён: ${why}`; + } else if (confirmed) { + kind = 'owner-question'; + add.push('blocked'); + } else { + kind = 'reclassify'; to = 'S3-spec'; trackAfter = 'ask'; + add.push('track:ask'); + if (!known || labels.includes('track:show')) remove.push('track:show'); + } + } + const spentAfter = spentBefore + 1; + const limitAfter = kind === 'reclassify' ? cycleLimit('ask') : limitBefore; + const exhausted = spentAfter >= limitAfter; + if (exhausted) add.push('review-4'); + return { + ...base, green: false, to, addLabels: known ? add.filter((label) => !labels.includes(label)) : add, removeLabels: remove, + exhausted, kind, note, track: trackAfter, spentAfter, limitAfter, + }; +} + +const reviewDocPath = (name) => `docs/reviews/${name}`; +const roundOf = (name) => Number((String(name).match(/-r(\d+)\.md$/) || [])[1]); + +/** + * Комментарий шага решения по вердикту (К3 #726); пустая строка — писать + * нечего (зелёный, обычный `fix`). Один комментарий на заход: исчерпание + * первым (по префиксу его узнаёт `wait-verdict.mjs`), затем маршрут, затем + * `note`. Машинная строка `hp:route` — последней. + * + * `docs` — документы этапа ЭТОЙ задачи из ветки материала `[{ name, text }]` + * (документ этого захода уже опубликован); `blocking` — имена прежних + * документов с блокирующим вердиктом. + */ +export function routeComment({ + decision, stage = 'code', num = '', cycle = '', branch = '', spent = 0, docs = [], blocking = [], runUrl = '', +} = {}) { + if (!decision || decision.kind === 'green') return ''; + const st = stage === 'spec' ? 'spec' : 'code'; + const marker = st === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW'; + const current = `${marker}-${num}-r${cycle}.md`; + const own = docs.map((doc) => doc.name).filter((name) => Number.isFinite(roundOf(name))) + .sort((a, b) => roundOf(a) - roundOf(b)); + if (!own.includes(current)) own.push(current); + const where = branch ? `ветки \`${branch}\`` : '`dev`'; + const id = decision.criterion; + const text = id ? SHOW_CRITERIA[id] : ''; + const parts = []; + if (decision.exhausted) { + const previous = blocking.filter((name) => name !== current).sort((a, b) => roundOf(a) - roundOf(b)); + const rest = nonNegative(spent, 0) - previous.length; + const options = [ + '1. **разделить** — issue закрывается как «заменён», вместо него 2–3 меньших с ясным скоупом;', + '2. **отклонить** — цена решения оказалась выше ценности;', + '3. **арбитраж владельца** — решение фиксируется в issue и принимается как есть.', + ]; + if (decision.track === 'show' && decision.kind !== 'reclassify') { + options[2] = options[2].replace(/\.$/, ';'); + options.push('4. **повысить до `ask`**: лимит станет 4, `review-4` снимает владелец.'); + } + parts.push( + `Лимит циклов ревью исчерпан: блокирующих циклов ${decision.spentAfter} из ${decision.limitAfter} на этапе \`${st}\` — последний израсходовал этот вердикт (заход r${cycle}). Задача возвращена в \`${decision.to}\` и получила \`review-4\`: следующего захода нет, решение владельца (PROCESS.md §4).`, + [ + 'Учтены вердикты с блокирующими находками — зелёные бюджет не тратят:', + ...previous.map((name) => `- \`${reviewDocPath(name)}\``), + ...(rest > 0 ? [`- ещё ${rest} — по комментариям с вердиктом (страховка счёта, #454)`] : []), + `- \`${reviewDocPath(current)}\` — этот заход`, + ].join('\n'), + ['Варианты решения (§4):', ...options].join('\n'), + ); + } + if (decision.kind === 'reclassify') { + parts.push( + '**Ревью show: решать есть что — трек повышен до `track:ask`.**', + `Задача не проходит критерий §5: **${text}** (\`${id}\`). Трек \`show\` держится на «решать нечего», а ревью нашло, что решать есть что (PROCESS.md §5).`, + [`Документы код-ревью ${where}:`, ...own.map((name) => `- \`${reviewDocPath(name)}\``)].join('\n'), + 'Задача переведена в `S3-spec`: код остаётся в ветке; полное ТЗ по §7.1 — в теле issue под `## ТЗ`; коммиты класса A — после `S5`. Дальше — ревью ТЗ (`S4-spec-review`) со своим бюджетом этапа `spec`.', + `Бюджет код-ревью: ${decision.spentAfter}/${decision.limitAfter} — блокирующие вердикты прежних заходов остаются в счёте, у \`ask\` лимит ${cycleLimit('ask')} (PROCESS.md §4).`, + ); + } else if (decision.kind === 'owner-question') { + parts.push( + '**Ревью show: решать есть что — вопрос владельцу.**', + `Трек \`show\` подтверждён владельцем, и конвейер его не повышает (PROCESS.md §5): задача в \`${decision.to}\` с \`blocked\`.`, + [ + `- **Что неясно:** выполнен ли критерий §5 «${text}» (\`${id}\`). Ревью считает, что нет: вердикт — \`${reviewDocPath(current)}\` ${where} и комментарий ревьюера.`, + `- **Что изменится от ответа:** \`ask\` — полное ТЗ по §7.1 и ревью ТЗ до новой правки кода, лимит код-ревью ${cycleLimit('ask')}; \`show\` — автор чинит по вердикту, лимит ${decision.limitAfter}.`, + '- **Вариант по умолчанию:** повысить до `track:ask`.', + ].join('\n'), + 'Как ответить: повысить — метка `track:ask` (и строка `Трек: ask — решение владельца`) и снять `blocked`, задача уходит в `S3-spec` на полное ТЗ; оставить `show` — снять `blocked`, автор чинит по вердикту.', + `Бюджет код-ревью: ${decision.spentAfter}/${decision.limitAfter}.`, + ); + } + if (decision.note) { + const [first, ...tail] = decision.note; + parts.push(`${first.toUpperCase()}${tail.join('')}. Вердикт возвращает задачу автору как \`fix\`: \`${decision.to}\`.`); + } + if (!parts.length) return ''; + if (runUrl) parts.push(`[Прогон](${runUrl}).`); + if (decision.kind === 'reclassify' || decision.kind === 'owner-question') { + parts.push(``); + } + return `${parts.join('\n\n')}\n`; +} + +/** Строка сводки прогона: маршрут и бюджет после вердикта (К4 #726). */ +export function routeSummary({ decision, stage = 'code' } = {}) { + const criterion = decision.criterion ? ` (критерий \`${decision.criterion}\`)` : ''; + return `- маршрут вердикта **${decision.kind}**${criterion} → \`${decision.to}\` · блокирующих циклов этапа ${stage === 'spec' ? 'spec' : 'code'}: ${decision.spentAfter}/${decision.limitAfter}${decision.exhausted ? ' · `review-4`' : ''}${decision.note ? ` · ${decision.note}` : ''}\n`; +} + function git(args) { const r = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${(r.stderr || '').trim()}`); @@ -369,24 +587,73 @@ if (isMainModule(import.meta.url)) { mkdirSync(out, { recursive: true }); if (decision.raise) writeFileSync(join(out, 'raise.md'), decision.comment); const basis = `${decision.basis}${decision.warning ? ` · внимание: ${decision.warning}` : ''}`; + // #726: `confirmed` — для шага решения по вердикту (owner-question). const lines = [ `track=${decision.track}`, `mutants=${decision.mutants}`, `full=${decision.full}`, `ship=${decision.ship}`, - `raise=${decision.raise}`, `basis=${basis}`, `risk=${decision.risk.classes.join(',')}`, + `raise=${decision.raise}`, `confirmed=${decision.confirmed}`, `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 (decision.routeNote) console.log(`route_note:\n${decision.routeNote}`); 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)}`); + appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n${block('risk_note', decision.note)}${block('route_note', decision.routeNote)}${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 === 'route') { + // #726: шаг «Решение по вердикту» — один вызов на заход модели. + const out = value('out'); + if (!out) throw new Error('--out is required'); + const stage = value('stage') === 'spec' ? 'spec' : 'code'; + const verdictPath = value('verdict'); + if (!verdictPath || !existsSync(verdictPath)) throw new Error(`verdict.json не найден: ${verdictPath || '(путь не задан)'}`); + const verdict = JSON.parse(readFileSync(verdictPath, 'utf8')); + // Граница доверия уже судила этот файл (#556); повтор — защита от сбоя шага. + const problems = verdictProblems(verdict); + if (problems.length) throw new Error(`вердикт отвергнут: ${problems.join('; ')}`); + for (const [name, fallback] of [['spent', '0'], ['limit', '4']]) { + if (nonNegative(value(name), null) === null) console.log(`::warning::--${name}=«${value(name)}» не число — берётся ${fallback}, как в guard`); + } + const num = value('num'); + const cycle = value('cycle'); + const decision = reviewRoute({ + stage, track: value('track') || 'ask', confirmed: value('confirmed') === 'true', + verdict: verdict.verdict, high: verdict.high, route: verdictRoute(verdict), criterion: verdict.criterion ?? '', + spent: value('spent'), limit: nonNegative(value('limit'), 4), labels: rest.some((a) => a.startsWith('--labels=')) ? labels : null, + }); + // Документы этапа этой задачи — из ветки материала (рабочая копия после публикации). + const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW'; + const ref = value('ref') || 'HEAD'; + const listed = spawnSync('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { encoding: 'utf8' }); + const own = new RegExp(`^${marker}-${/^\d+$/.test(num) ? num : 'x'}-r\\d+\\.md$`); + const docs = (listed.status === 0 ? listed.stdout : '').split('\n').filter((name) => own.test(name)).map((name) => { + const shown = spawnSync('git', ['show', `${ref}:docs/reviews/${name}`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + return { name, text: shown.status === 0 ? shown.stdout : '' }; + }); + const comment = routeComment({ + decision, stage, num, cycle, branch: value('branch'), spent: nonNegative(value('spent'), 0), docs, + blocking: blockingFromDocs(docs).blocking, runUrl: value('run-url'), + }); + mkdirSync(out, { recursive: true }); + const commentPath = join(out, 'comment.md'); + rmSync(commentPath, { force: true }); + if (comment) writeFileSync(commentPath, comment); + for (const line of [ + `kind=${decision.kind}`, `green=${decision.green}`, `verdict=${verdict.verdict}`, `high=${verdict.high}`, + `from=${decision.from}`, `to=${decision.to}`, + `add_labels=${decision.addLabels.join(',')}`, `remove_labels=${decision.removeLabels.join(',')}`, + `exhausted=${decision.exhausted}`, `spent_after=${decision.spentAfter}`, `limit_after=${decision.limitAfter}`, + `track=${decision.track}`, `criterion=${decision.criterion}`, `note=${decision.note}`, + `comment=${comment ? commentPath : ''}`, + ]) console.log(line); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, routeSummary({ decision, stage })); } else if (command === 'limit') { const path = value('files'); const files = path && existsSync(path) ? readFileSync(path, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean) : []; @@ -404,7 +671,7 @@ if (isMainModule(import.meta.url)) { }); emit([`ship=${violations.length === 0}`, `violations=${violations.join('; ')}`]); } else { - 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=]'); + throw new Error('usage: process-track.mjs stage --stage=code|spec --labels=a,b --branch= --base= --out= [--head --comments --owner --run-url] | route --stage=code|spec --track= --confirmed=true|false --verdict= --spent=N --limit=N --num=NN --cycle=N --out= [--labels --branch --ref --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/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index f8bade00..74ffa954 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -307,7 +307,12 @@ export function issueBodyChanged(docs, digest) { return recorded === digest ? null : { doc: green.name, recorded, current: digest }; } -export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody } = {}) { +/** Маршруты вердикта в якоре (#726) — тот же словарь, что у границы доверия. */ +const ANCHOR_ROUTES = ['fix', 'reclassify']; +/** Идентификатор критерия в якоре: только формат, смысл судит `reviewRoute`. */ +const ANCHOR_CRITERION = /^[a-z][a-z0-9-]{0,39}$/; + +export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody, route, criterion } = {}) { const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : ''); const lines = [ ANCHOR_MARKER, @@ -343,8 +348,17 @@ export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, hi // следующий заход решает, можно ли применить зелёный вердикт повторно без // вызова модели. Прозу документа для этого читать нельзя — она цитирует // прошлые раунды и пишется в свободной форме. + // + // #726: хвост «· маршрут `reclassify` (критерий `undocumented`)» — после + // прежнего текста строки: `anchorVerdictFrom` читает старую и новую одинаково. + // Маршрута нет (вердикт до #726) — строка прежняя. if (verdict) { - lines.push(`- Вердикт конвейера: \`${verdict}\` · High ${Number.isFinite(Number(high)) ? Number(high) : '?'}`); + let line = `- Вердикт конвейера: \`${verdict}\` · High ${Number.isFinite(Number(high)) ? Number(high) : '?'}`; + if (ANCHOR_ROUTES.includes(route)) { + line += ` · маршрут \`${route}\``; + if (ANCHOR_CRITERION.test(String(criterion ?? ''))) line += ` (критерий \`${criterion}\`)`; + } + lines.push(line); } return `${lines.join('\n')}\n`; } @@ -789,6 +803,9 @@ if (invokedDirectly) { specs: parseSpecList(value('specs')), verdict: ['green', 'yellow', 'red'].includes(value('verdict')) ? value('verdict') : '', high: value('high'), + // #726: маршрут и критерий вердикта; вне словаря и формата — не пишутся. + route: value('route'), + criterion: value('criterion'), }; const text = readFileSync(path, 'utf8'); writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8'); diff --git a/scripts/review-result-gate.mjs b/scripts/review-result-gate.mjs index eab110ac..2952d04c 100644 --- a/scripts/review-result-gate.mjs +++ b/scripts/review-result-gate.mjs @@ -31,9 +31,42 @@ export const PASSPORT_FIELDS = [ 'spec_body_changed', 'spec_body_doc', 'spec_body_recorded', ]; export const VERDICTS = ['green', 'yellow', 'red']; +/** + * Маршрут вердикта (#726): `fix` — вернуть автору по находкам; `reclassify` — + * трек `show` выбран неверно, задача не проходит критерий §5. Смысл + * `criterion` судит `reviewRoute` (`process-track.mjs`), а не граница. + */ +export const ROUTES = ['fix', 'reclassify']; export const sha256 = (text) => createHash('sha256').update(text).digest('hex'); +/** + * Маршрут структурного вердикта: поля нет (вердикты до #726, модель его не + * заполнила) — `fix`, прежний путь; значение вне словаря — `null`. + */ +export function verdictRoute(verdict) { + if (verdict?.route == null) return 'fix'; + return ROUTES.includes(verdict.route) ? verdict.route : null; +} + +/** Причины отказа самого `verdict.json`; пустой массив — вердикт цел. */ +export function verdictProblems(verdict) { + const problems = []; + if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`); + if (typeof verdict?.high !== 'number') problems.push('verdict.high не число'); + if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число'); + if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка'); + const route = verdictRoute(verdict); + if (route === null) { + problems.push(`route вне словаря: ${JSON.stringify(verdict?.route)}`); + } else if (route === 'reclassify' && verdict?.verdict === 'green') { + // Противоречивый результат: «вперёд» и «трек неверен» сразу. Fail-closed, + // молчаливый green здесь хуже остановки (#726, порядок владельца #707). + problems.push('reclassify при зелёном вердикте — результат противоречив'); + } + return problems; +} + /** Разбор строки `sha256sum`: « <имя>». */ export function parseManifest(text) { const rows = []; @@ -97,11 +130,7 @@ export function reviewResultProblems({ files, read, expected }) { } catch (error) { return [...problems, `verdict.json не разобран: ${error.message}`]; } - if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`); - if (typeof verdict?.high !== 'number') problems.push('verdict.high не число'); - if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число'); - if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка'); - return problems; + return [...problems, ...verdictProblems(verdict)]; } const invokedDirectly = process.argv[1] diff --git a/scripts/wait-verdict.mjs b/scripts/wait-verdict.mjs index b18ed735..73f1cb37 100644 --- a/scripts/wait-verdict.mjs +++ b/scripts/wait-verdict.mjs @@ -7,7 +7,8 @@ // Этот скрипт делает опрос сам и ГОВОРИТ только при смене состояния: одинаковое // состояние не будит никого. Что доставляется: смена статусной метки (вердикт), // отказ конвейера (комментарий «Ревью не запускалось» / «Слияние отменено» / -// «Автоматическое ревью не отработало»), `blocked`, `review-4`, а при `--sha` — +// «Автоматическое ревью не отработало»), маршрут вердикта show (#726: трек +// повышен до ask / вопрос владельцу), `blocked`, `review-4`, а при `--sha` — // исход Validate на этом SHA. Исторические комментарии до последнего запроса // S4/S7 образуют baseline: иначе новый раунд немедленно завершался по старому // failure. Событие текущего раунда, даже опубликованное до запуска waiter, @@ -36,6 +37,10 @@ export const PIPELINE_EVENTS = [ { re: /^\*\*Код-ревью зелёное — вердикт выше в силе/m, kind: 'merge-conflict', text: 'конвейер: вердикт зелёный, слияние конфликтует — ребейз (rebase-on-dev.mjs) и снова S7' }, { re: /^Автоматическое ревью не отработало/m, kind: 'failure', text: 'конвейер: прогон ревью упал — метка не менялась, смотреть логи, сообщить владельцу' }, { re: /^Лимит циклов ревью исчерпан/m, kind: 'exhausted', text: 'конвейер: лимит циклов исчерпан — решение владельца' }, + // #726: маршрут вердикта show. После исчерпания: тот же комментарий может нести + // и маршрут, но решение тогда за владельцем — первым находится `exhausted`. + { re: /^\*\*Ревью show: решать есть что — трек повышен до `track:ask`\.\*\*/m, kind: 'reclassify', text: 'конвейер: трек повышен до ask: полное ТЗ в теле issue, код класса A не пушить до S5' }, + { re: /^\*\*Ревью show: решать есть что — вопрос владельцу\.\*\*/m, kind: 'owner-question', text: 'конвейер: вопрос владельцу о треке show — ждёт владельца' }, { re: /^Конвейер ревью не запущен:/m, kind: 'refused', text: 'конвейер отказал (blocked/review-4) — читать комментарий' }, ]; diff --git a/test/process-digests.test.mjs b/test/process-digests.test.mjs index f2da6b0c..20b6bf32 100644 --- a/test/process-digests.test.mjs +++ b/test/process-digests.test.mjs @@ -68,6 +68,8 @@ const KEY_RULES = { ['8-гейты', 'Полные наборы — предрелизный гейт, а не гейт ревью'], ['8-гейты', 'Упавший предрелизный гейт автор чинит и повторно прогоняет'], ['3-правила', 'проверено чтением, не исполнением'], + // #726: исчерпание видно сразу, а не на следующем S7. + ['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'], ['104-событийный-конвейер-метка-как-триггер', 'Один хендофф — один пуш'], ['104-событийный-конвейер-метка-как-триггер', 'Ветка приводится к `dev` до ревью, а не после'], ['104-событийный-конвейер-метка-как-триггер', 'Автор обязан дождаться вердикта, а не заканчивать сессию'], @@ -100,6 +102,7 @@ const KEY_RULES = { ['72-шаблоны-комментариев', '`Вердикт: зелёный/жёлтый/красный · заход r · блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`'], ['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'], ['4-лимит-циклов-ревью-4', 'Зелёный вердикт цикла не образует'], + ['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'], ['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'], ['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'], ], diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index a0cfae02..1460b447 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -6,10 +6,12 @@ import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - 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, + COMPARE_FILES_CAP, RISK_CLASSES, RISK_NOTE_LINE_LIMIT, SHIP_SRC_LINE_LIMIT, SHOW_CRITERIA, classifyRisk, cycleLimit, decideTrack, + explicitTracks, guardLimit, hasTrackLabel, parseNameStatus, parseNumstat, rebaseBeforeReview, resolveTrack, reviewRoute, riskClassLine, + riskNote, routeComment, routeNote, routeSummary, shipLimitViolations, trackFromLabels, trackOrigin, } from '../scripts/process-track.mjs'; +import { reviewCounters, reviewRoundsFromFiles, withMaterialAnchors } from '../scripts/review-doc-guard.mjs'; +import { sectionText } from '../scripts/md-anchors.mjs'; import { classify as classifyPath } from '../scripts/change-classes.mjs'; // Пути монолитов — данные для классификатора, а не чтение их текста (#624). import { CARD_FILE, RUNTIME_FILE } from '../scripts/monolith-metrics.mjs'; @@ -399,10 +401,12 @@ 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/); + // #726: вместо Medium «решать есть что — нужен track:ask» — маршрут вердикта. + assert.match(showNote, /не нашёл — `route: reclassify` с названным критерием §5 \(#726\)/); + assert.doesNotMatch(showNote, /Medium/); 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.match(confirmedNote, /трек не повышать: не нашёл — `route: reclassify` с критерием, и конвейер задаст вопрос владельцу, вариант по умолчанию «повысить до 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;']] }])); @@ -515,12 +519,14 @@ function stepRun(workflow, marker) { const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#696)"\n'; const GUARD_STEP = ' - id: decide\n'; const DECIDE_STEP = ' - name: Решение по вердикту\n'; +const PUBLISH_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]) { + // #726: шаг решения и публикация документа тоже изменены. + for (const marker of [TRACK_STEP, GUARD_STEP, DECIDE_STEP, PUBLISH_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}`); @@ -628,6 +634,10 @@ function trackSandbox(t, { change }) { writeFileSync(join(bin, 'gh'), [ '#!/usr/bin/env bash', 'printf \'%s\\n\' "$*" >> "$FAKE_DIR/gh-calls"', + // #726: `--json labels` — текущие метки для шага решения по вердикту (как их отдал бы --jq). + 'if [ "$1 $2" = "issue view" ] && [[ " $* " == *" labels "* ]]; then', + ' if [ -f "$FAKE_DIR/labels" ]; then cat "$FAKE_DIR/labels"; exit 0; fi; echo "gh: API недоступен" >&2; exit 1', + 'fi', '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 ;;', @@ -659,6 +669,10 @@ function trackSandbox(t, { change }) { if (list === null) rmSync(join(fake, 'comments.json'), { force: true }); else writeFileSync(join(fake, 'comments.json'), JSON.stringify({ comments: list })); }, + labels(list) { + if (list === null) rmSync(join(fake, 'labels'), { force: true }); + else writeFileSync(join(fake, 'labels'), `${list.join(',')}\n`); + }, }; } @@ -747,3 +761,350 @@ test('#707 AC4: шаг трека на настоящем bash — коммен assert.equal(show.calls.length, 1, 'show не трогает меток'); assert.match(show.output.risk_note, /- touch: src\/pointer-modality\.ts:3/); }); + +// ---------- #726: маршрут вердикта — reclassify, вопрос владельцу, немедленный review-4 ---------- + +const SHOW = { stage: 'code', track: 'show', limit: 2 }; +const pick = (d) => ({ to: d.to, addLabels: d.addLabels, removeLabels: d.removeLabels, kind: d.kind }); + +test('#726 AC2: таблица маршрутов — каждая строка даёт точные to, метки и kind', () => { + const at = (over) => reviewRoute({ ...SHOW, verdict: 'yellow', high: 0, spent: 0, ...over }); + // green, High 0 — как сейчас: вперёд, меток нет. + assert.deepEqual(pick(at({ verdict: 'green' })), { to: 'S8-merged', addLabels: [], removeLabels: [], kind: 'green' }); + assert.deepEqual(pick(reviewRoute({ stage: 'spec', track: 'ask', verdict: 'green', high: 0, spent: 0, limit: 4 })), + { to: 'S5-ready', addLabels: [], removeLabels: [], kind: 'green' }); + assert.equal(at({ verdict: 'green', high: 1 }).kind, 'fix', 'зелёный с High — не зелёный'); + // code, show, не зелёный, reclassify, критерий из таблицы, не confirmed — ask и S3. + for (const verdict of ['yellow', 'red']) { + assert.deepEqual(pick(at({ verdict, route: 'reclassify', criterion: 'undocumented' })), + { to: 'S3-spec', addLabels: ['track:ask'], removeLabels: ['track:show'], kind: 'reclassify' }, verdict); + } + for (const id of Object.keys(SHOW_CRITERIA)) assert.equal(at({ route: 'reclassify', criterion: id }).kind, 'reclassify', id); + assert.deepEqual(Object.keys(SHOW_CRITERIA), ['complexity', 'surfaces', 'migration', 'ux-contract', 'perf-touch', 'undocumented']); + assert.equal(at({ route: 'reclassify', criterion: 'migration', verdict: 'red', high: 2 }).kind, 'reclassify', 'High > 0 — маршрут тот же'); + // то же, confirmed — трек не меняется, blocked и вопрос владельцу. + const owner = at({ route: 'reclassify', criterion: 'undocumented', confirmed: true }); + assert.deepEqual(pick(owner), { to: 'S6-in-progress', addLabels: ['blocked'], removeLabels: [], kind: 'owner-question' }); + assert.equal(owner.track, 'show', 'подтверждённый show не повышается'); + // не зелёный, fix — как сейчас. + assert.deepEqual(pick(at({ route: 'fix' })), { to: 'S6-in-progress', addLabels: [], removeLabels: [], kind: 'fix' }); + assert.deepEqual(pick(reviewRoute({ stage: 'spec', track: 'ask', verdict: 'red', spent: 0, limit: 4 })), + { to: 'S3-spec', addLabels: [], removeLabels: [], kind: 'fix' }); + // reclassify на ask, на spec, без критерия из таблицы — как fix с note. + const ask = reviewRoute({ stage: 'code', track: 'ask', verdict: 'yellow', route: 'reclassify', criterion: 'undocumented', spent: 0, limit: 4 }); + assert.deepEqual(pick(ask), { to: 'S6-in-progress', addLabels: [], removeLabels: [], kind: 'fix' }); + assert.equal(ask.track, 'ask'); + assert.match(ask.note, /^маршрут reclassify не применён: трек `ask`/); + const spec = reviewRoute({ stage: 'spec', track: 'show', verdict: 'yellow', route: 'reclassify', criterion: 'undocumented', spent: 0, limit: 2 }); + assert.deepEqual(pick(spec), { to: 'S3-spec', addLabels: [], removeLabels: [], kind: 'fix' }); + assert.match(spec.note, /^маршрут reclassify не применён: этап `spec`/); + for (const criterion of ['', undefined, null]) { + const none = at({ route: 'reclassify', criterion }); + assert.deepEqual(pick(none), { to: 'S6-in-progress', addLabels: [], removeLabels: [], kind: 'fix' }, String(criterion)); + assert.equal(none.note, 'маршрут reclassify не применён: критерий §5 не назван'); + } + for (const criterion of ['vibes', 'UNDOCUMENTED', 'constructor', '__proto__', 42, { id: 'undocumented' }]) { + const odd = at({ route: 'reclassify', criterion }); + assert.equal(odd.kind, 'fix', JSON.stringify(criterion)); + assert.equal(odd.track, 'show', 'трек не меняется'); + assert.match(odd.note, /^маршрут reclassify не применён: критерий `[^`]*` не из списка §5 \(complexity, surfaces, migration, ux-contract, perf-touch, undocumented\)$/); + } + assert.equal(at({ route: 'reclassify', criterion: 'vibes' }).note.includes('`vibes`'), true); + // Текущие метки известны: снимается только стоящее, стоящее не ставится второй раз. + const infra = at({ route: 'reclassify', criterion: 'surfaces', labels: ['S7-code-review', 'infra'] }); + assert.deepEqual([infra.addLabels, infra.removeLabels], [['track:ask'], []], 'инфраструктура без трековой метки — снимать нечего'); + assert.deepEqual(at({ route: 'reclassify', criterion: 'surfaces', confirmed: true, labels: ['track:show', 'blocked'] }).addLabels, [], + 'blocked уже стоит — метка одна'); +}); + +test('#726 AC3: вердикт, исчерпавший бюджет, сразу ставит review-4; статус двигается по таблице', () => { + const at = (over) => reviewRoute({ verdict: 'yellow', high: 0, route: 'fix', ...over }); + const row = (d) => ({ to: d.to, exhausted: d.exhausted, review4: d.addLabels.includes('review-4'), budget: `${d.spentAfter}/${d.limitAfter}` }); + assert.deepEqual(row(at({ ...SHOW, spent: 1 })), { to: 'S6-in-progress', exhausted: true, review4: true, budget: '2/2' }, 'show, spent 1, fix'); + assert.deepEqual(row(at({ ...SHOW, spent: 1, route: 'reclassify', criterion: 'undocumented' })), + { to: 'S3-spec', exhausted: false, review4: false, budget: '2/4' }, 'show, spent 1, reclassify — лимит ask'); + assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 3, limit: 4 })), { to: 'S6-in-progress', exhausted: true, review4: true, budget: '4/4' }); + assert.deepEqual(row(at({ stage: 'spec', track: 'ask', spent: 3, limit: 4 })), { to: 'S3-spec', exhausted: true, review4: true, budget: '4/4' }); + assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 2, limit: 4 })), { to: 'S6-in-progress', exhausted: false, review4: false, budget: '3/4' }); + assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 3, limit: 4, verdict: 'green' })), + { to: 'S8-merged', exhausted: false, review4: false, budget: '3/4' }, 'зелёный цикла не образует'); + assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 3, limit: 4, verdict: 'red' })).review4, true, 'красный — тоже цикл'); + // Вопрос владельцу на show при spent 1 — и вопрос, и review-4: лимит show не меняется. + assert.deepEqual(at({ ...SHOW, spent: 1, route: 'reclassify', criterion: 'undocumented', confirmed: true }).addLabels, ['blocked', 'review-4']); + // Входы guard не прочитаны — значения guard по умолчанию: 0 циклов, лимит трека. + assert.equal(at({ stage: 'code', track: 'show', spent: '', limit: '' }).spentAfter, 1); + assert.equal(at({ stage: 'code', track: 'show', spent: 'x', limit: undefined }).limitAfter, 2); +}); + +test('#726 AC4: бюджет код-ревью один на все треки — SPEC в счёт не входит, у ask лимит 4, r4 ставит review-4', () => { + const doc = (round, route, criterion, colour = 'жёлтый') => withMaterialAnchors(`# REVIEW-7-r${round}\n\nВердикт: ${colour} · заход r${round} · High: 0 · Medium: 1\n`, + { sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'issue/7-x', verdict: colour === 'зелёный' ? 'green' : 'yellow', high: 0, route, criterion }); + const tree = { + 'CODE-REVIEW-7-r1.md': doc(1, 'fix'), // show + 'CODE-REVIEW-7-r2.md': doc(2, 'reclassify', 'undocumented'), + // Ревью ТЗ после reclassify — три захода, два блокирующих: свой этап и свой бюджет. + 'SPEC-REVIEW-7-r1.md': doc(1, 'fix'), + 'SPEC-REVIEW-7-r2.md': doc(2, 'fix'), + 'SPEC-REVIEW-7-r3.md': doc(3, 'fix', '', 'зелёный'), + }; + // Счёт guard: те же функции, что у `review-doc-guard.mjs --counters`. + const counters = (marker, files) => { + const { rounds } = reviewRoundsFromFiles(Object.keys(files), marker, '7'); + return reviewCounters({ rounds, docs: rounds.map((r) => ({ name: `${marker}-7-r${r}.md`, text: files[`${marker}-7-r${r}.md`] })) }); + }; + // r2 на show: reclassify при одном прошлом цикле — ask, лимит 4, без review-4. + const r2 = reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'undocumented' }); + assert.deepEqual([r2.kind, r2.exhausted, r2.spentAfter, r2.limitAfter], ['reclassify', false, 2, 4]); + // Заход r3 после ревью ТЗ: трек ask, лимит 4, счёт кода — прежние 2. + const code = counters('CODE-REVIEW', tree); + assert.deepEqual([code.spent, code.attempt], [2, 3]); + const { track, limit } = guardLimit({ labels: ['track:ask', 'S7-code-review'], files: ['src/a.ts'] }); + assert.deepEqual([track, limit], ['ask', cycleLimit('ask')]); + assert.ok(code.spent < limit, 'guard пускает r3 (`spent -ge limit` ложно)'); + assert.deepEqual([counters('SPEC-REVIEW', tree).spent, counters('SPEC-REVIEW', tree).attempt], [2, 4], 'у ревью ТЗ свой бюджет'); + assert.deepEqual([counters('CODE-REVIEW', { ...tree, 'SPEC-REVIEW-7-r4.md': doc(4, 'fix') }).spent, counters('CODE-REVIEW', { ...tree, 'SPEC-REVIEW-7-r4.md': doc(4, 'fix') }).attempt], [2, 3], + 'SPEC-документы ни бюджет кода не тратят, ни номер захода не сдвигают'); + // Жёлтый r3 — без review-4; жёлтый r4 — review-4 сразу. + const r3 = reviewRoute({ stage: 'code', track, verdict: 'yellow', spent: code.spent, limit }); + assert.deepEqual([r3.exhausted, r3.spentAfter], [false, 3]); + const after3 = counters('CODE-REVIEW', { ...tree, 'CODE-REVIEW-7-r3.md': doc(3, 'fix') }); + assert.deepEqual([after3.spent, after3.attempt], [3, 4]); + const r4 = reviewRoute({ stage: 'code', track, verdict: 'yellow', spent: after3.spent, limit }); + assert.deepEqual([r4.exhausted, r4.addLabels, r4.to, r4.spentAfter], [true, ['review-4'], 'S6-in-progress', 4]); +}); + +test('#726 К3: комментарии маршрута — первая строка, критерий словами, документы путями, бюджет, hp:route', () => { + const docs = [{ name: 'CODE-REVIEW-7-r2.md', text: '' }, { name: 'CODE-REVIEW-7-r1.md', text: '' }]; + const raised = routeComment({ + decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'undocumented' }), + num: '7', cycle: '2', branch: 'issue/7-x', spent: 1, docs, blocking: ['CODE-REVIEW-7-r1.md'], runUrl: 'https://run/1', + }); + assert.equal(raised.split('\n')[0], '**Ревью show: решать есть что — трек повышен до `track:ask`.**'); + assert.ok(raised.includes(`**${SHOW_CRITERIA.undocumented}** (\`undocumented\`)`), 'критерий словами'); + assert.match(raised, /Документы код-ревью ветки `issue\/7-x`:\n- `docs\/reviews\/CODE-REVIEW-7-r1\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n/); + assert.ok(raised.includes('код остаётся в ветке; полное ТЗ по §7.1 — в теле issue под `## ТЗ`; коммиты класса A — после `S5`')); + assert.match(raised, /Бюджет код-ревью: 2\/4/); + assert.match(raised, /\[Прогон\]\(https:\/\/run\/1\)\.\n\n\n$/); + assert.doesNotMatch(raised, /Лимит циклов/); + + const question = routeComment({ + decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0, route: 'reclassify', criterion: 'perf-touch', confirmed: true }), + num: '7', cycle: '1', branch: 'issue/7-x', spent: 0, docs: [{ name: 'CODE-REVIEW-7-r1.md', text: '' }], + }); + assert.equal(question.split('\n')[0], '**Ревью show: решать есть что — вопрос владельцу.**'); + assert.match(question, /- \*\*Что неясно:\*\* выполнен ли критерий §5 «нет влияния на производительность и на touch-контракт» \(`perf-touch`\)\. Ревью считает, что нет: вердикт — `docs\/reviews\/CODE-REVIEW-7-r1\.md`/); + assert.match(question, /\n- \*\*Что изменится от ответа:\*\* [^\n]+\n- \*\*Вариант по умолчанию:\*\* повысить до `track:ask`\.\n/, 'одним блоком по §7.1'); + assert.match(question, /Как ответить: повысить — метка `track:ask` \(и строка `Трек: ask — решение владельца`\) и снять `blocked`[^;]*; оставить `show` — снять `blocked`, автор чинит по вердикту\./); + assert.match(question, /\n$/); + assert.doesNotMatch(question, /review-4|Лимит циклов/); + // Конвейер пишет от учётной записи владельца: его комментарии трек не подтверждают. + for (const body of [raised, question]) { + assert.equal(trackOrigin({ labels: ['track:ask'], owner: 'Matysh', comments: [{ author: 'Matysh', body, createdAt: '2' }] }).confirmed, false); + } + + // Исчерпание: прежний префикс первой строкой, счёт, перечень, варианты §4 и четвёртый на show. + const r = (n) => ({ name: `CODE-REVIEW-7-r${n}.md`, text: '' }); + const exhausted = routeComment({ + decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1 }), num: '7', cycle: '3', branch: 'issue/7-x', spent: 1, + docs: [r(1), r(2), r(3)], blocking: ['CODE-REVIEW-7-r2.md', 'CODE-REVIEW-7-r3.md'], + }); + assert.match(exhausted, /^Лимит циклов ревью исчерпан: блокирующих циклов 2 из 2 на этапе `code` — последний израсходовал этот вердикт \(заход r3\)\. Задача возвращена в `S6-in-progress` и получила `review-4`/); + assert.match(exhausted, /зелёные бюджет не тратят:\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r3\.md` — этот заход\n/, 'зелёный r1 не учтён'); + assert.match(exhausted, /1\. \*\*разделить\*\*[^\n]+\n2\. \*\*отклонить\*\*[^\n]+\n3\. \*\*арбитраж владельца\*\*[^\n]+;\n4\. \*\*повысить до `ask`\*\*: лимит станет 4, `review-4` снимает владелец\.\n/); + assert.doesNotMatch(exhausted, /hp:route/); + const onAsk = routeComment({ decision: reviewRoute({ stage: 'code', track: 'ask', verdict: 'red', spent: 3, limit: 4 }), num: '7', cycle: '5', spent: 3, blocking: ['CODE-REVIEW-7-r2.md'] }); + assert.match(onAsk, /^Лимит циклов ревью исчерпан: блокирующих циклов 4 из 4/); + assert.match(onAsk, /- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n- ещё 2 — по комментариям с вердиктом \(страховка счёта, #454\)\n- `docs\/reviews\/CODE-REVIEW-7-r5\.md` — этот заход/, + 'счёт по комментариям виден в перечне'); + assert.doesNotMatch(onAsk, /повысить до/, 'на ask четвёртого варианта нет'); + // Исчерпание с вопросом владельцу — один комментарий, исчерпание первым. + const both = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'surfaces', confirmed: true }), num: '7', cycle: '2', spent: 1 }); + assert.match(both, /^Лимит циклов ревью исчерпан[\s\S]+\n\n\*\*Ревью show: решать есть что — вопрос владельцу\.\*\*\n[\s\S]+\n$/); + + // fix с note — строка «маршрут reclassify не применён: <причина>»; без note и зелёный — комментария нет. + const note = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0, route: 'reclassify', criterion: 'vibes' }), num: '7', cycle: '1', spent: 0 }); + assert.match(note, /^Маршрут reclassify не применён: критерий `vibes` не из списка §5 [^\n]+\. Вердикт возвращает задачу автору как `fix`: `S6-in-progress`\.\n$/); + const noteExhausted = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify' }), num: '7', cycle: '2', spent: 1 }); + assert.match(noteExhausted, /^Лимит циклов ревью исчерпан[\s\S]+\n\nМаршрут reclassify не применён: критерий §5 не назван\./, 'note — в том же комментарии'); + assert.equal(routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0 }), num: '7', cycle: '1' }), ''); + assert.equal(routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'green', spent: 1 }), num: '7', cycle: '2' }), ''); + // Недоверенный criterion в текст конвейера не протекает. + const evil = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0, route: 'reclassify', criterion: 'q` --> ' }), num: '7', cycle: '1' }); + assert.match(evil, /критерий `q\?\?--\?\?\?b\?` не из списка/); + assert.doesNotMatch(evil, /q`|-->|/); + // Сводка прогона: маршрут и бюджет после вердикта. + assert.equal(routeSummary({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'undocumented' }) }), + '- маршрут вердикта **reclassify** (критерий `undocumented`) → `S3-spec` · блокирующих циклов этапа code: 2/4\n'); + assert.match(routeSummary({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1 }) }), /\*\*fix\*\* → `S6-in-progress` · блокирующих циклов этапа code: 2\/2 · `review-4`/); +}); + +test('#726 К1: критерии маршрута — пункты «Подсказки аналитику» §5 дословно', () => { + const canon = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'PROCESS.md'), 'utf8'); + const norm = (text) => text.replace(/\*\*/g, '').replace(/\s+/g, ' ').toLowerCase(); + const section = norm(sectionText(canon, '5-треки-ship-show-ask--метка-владельца')); + for (const [id, text] of Object.entries(SHOW_CRITERIA)) { + assert.ok(section.includes(norm(text)), `${id}: «${text}» нет в PROCESS.md §5`); + assert.ok(section.includes(`\`${id}\``), `${id}: идентификатор не назван в §5`); + } +}); + +test('#726 AC5: схема вердикта, заметка маршрута в промпте, публикация передаёт маршрут', () => { + const workflow = readFileSync(WORKFLOW, 'utf8'); + const schema = JSON.parse(/--json-schema '([^']+)'/.exec(workflow)[1]); + assert.deepEqual(schema.properties.route, { type: 'string', enum: ['fix', 'reclassify'] }); + assert.deepEqual(schema.properties.criterion, { type: 'string' }); + assert.ok(schema.required.includes('route'), 'route обязателен'); + assert.ok(!schema.required.includes('criterion'), 'criterion необязателен'); + const prompt = workflow.slice(workflow.indexOf(' prompt: |\n'), workflow.indexOf(' claude_args: |')); + assert.match(prompt, /\n\s+\$\{\{ needs\.prepare\.outputs\.route_note \}\}\n/, 'заметка маршрута доходит до промпта'); + const prepare = workflow.slice(workflow.indexOf('\n prepare:'), workflow.indexOf(' steps:', workflow.indexOf('\n prepare:'))); + assert.match(prepare, /route_note: \$\{\{ steps\.track\.outputs\.route_note \}\}/); + assert.match(prepare, /confirmed: \$\{\{ steps\.track\.outputs\.confirmed \}\}/); + // Текст заметки: на show/code — поля и список критериев, на прочих — route: fix. + const show = routeNote({ stage: 'code', track: 'show' }); + assert.match(show, /`route: reclassify` и `criterion` — если задача не проходит критерий §5 из списка ниже; иначе `route: fix`\. `reclassify` — не зелёный вердикт/); + for (const [id, text] of Object.entries(SHOW_CRITERIA)) assert.ok(show.includes(`\n- \`${id}\` — ${text}`), id); + assert.match(routeNote({ stage: 'code', track: 'show', confirmed: true }), /подтверждён владельцем — конвейер его не повысит, а поставит `blocked`/); + for (const other of [{ stage: 'code', track: 'ask' }, { stage: 'spec', track: 'show' }, { stage: 'spec', track: 'ask' }, { stage: 'code', track: 'ship' }]) { + assert.equal(routeNote(other), '**Маршрут вердикта (#726):** `route: fix`.', JSON.stringify(other)); + } + assert.equal(s7({ comments: ownerSays('ship') }).routeNote, '', 'ship в рамках модель не зовёт'); + // Публикация: маршрут и критерий — в якорь документа. + const publish = stepRun(workflow, PUBLISH_STEP); + assert.match(publish, /route=\$\(printf '%s' "\$OUT" \| jq -r '\.route \/\/ empty' 2>\/dev\/null \|\| true\)/); + assert.match(publish, /criterion=\$\(printf '%s' "\$OUT" \| jq -r '\.criterion \/\/ empty' 2>\/dev\/null \|\| true\)/); + assert.match(publish, /--verdict="\$verdict" --high="\$high" \\\n\s+--route="\$route" --criterion="\$criterion"\n/); +}); + +test('#726 AC5: шаг решения — один вызов process-track.mjs route, метки только по его выходу, ship и reuse его не зовут', () => { + const workflow = readFileSync(WORKFLOW, 'utf8'); + const run = stepRun(workflow, DECIDE_STEP); + assert.equal((run.match(/process-track\.mjs/g) || []).length, 1, 'один вызов скрипта'); + assert.match(run, /route=\$\(node "\$tools\/scripts\/process-track\.mjs" route --stage="\$STAGE" --track="\$TRACK" \\\n\s+--confirmed="\$CONFIRMED" --labels="\$labels" --verdict="\$RUNNER_TEMP\/route-verdict\.json"/); + assert.match(run, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev'); + assert.equal((run.match(/gh issue edit/g) || []).length, 1, 'метки меняются в одном месте'); + assert.match(run, /if \[ -n "\$add" \]; then edit\+=\(--add-label "\$add"\); fi\n\s+if \[ -n "\$remove" \]; then edit\+=\(--remove-label "\$remove"\); fi/); + assert.match(run, /add=\$\(field add_labels\); remove=\$\(field remove_labels\); comment=\$\(field comment\)/); + assert.match(run, /--body-file "\$comment"/, 'тело комментария — файл скрипта'); + assert.doesNotMatch(run, /--add-label (?:review-4|blocked|track:)/, 'ни одной метки, вписанной в bash'); + // Ветки ship и reuse — до вызова скрипта, без него. + const call = run.indexOf('process-track.mjs'); + assert.ok(run.indexOf('elif [ "$REUSE" = "true" ]; then') < call && run.indexOf('if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then') < call); + assert.equal((run.match(/green_forward$/gm) || []).length, 2, 'ship и reuse — прежнее зелёное решение'); + const step = workflow.slice(workflow.indexOf(DECIDE_STEP), workflow.indexOf(' - name: dev ушёл вперёд')); + for (const env of ['TRACK: ${{ needs.prepare.outputs.track }}', 'CONFIRMED: ${{ needs.prepare.outputs.confirmed }}', + 'SPENT: ${{ needs.guard.outputs.spent }}', 'LIMIT: ${{ needs.guard.outputs.limit }}', 'CYCLE: ${{ needs.guard.outputs.cycle }}', + 'BRANCH: ${{ needs.prepare.outputs.branch }}', 'LABELS: ${{ needs.guard.outputs.labels }}']) { + assert.ok(step.includes(` ${env}\n`), env); + } + // Многострочного текста в новой ветке нет: heredoc — только прежний комментарий слияния ship. + assert.equal((run.match(/< `# CODE-REVIEW-7-r${round}\n\nВердикт: ${colour} · заход r${round} · High: 0 · Medium: 1\n`; +/** Ветка материала после публикации: документы ревью этой задачи и чужой SPEC. */ +const docsChange = (rounds) => (work) => { + mkdirSync(join(work, 'docs', 'reviews'), { recursive: true }); + for (const [round, colour] of rounds) writeFileSync(join(work, 'docs', 'reviews', `CODE-REVIEW-7-r${round}.md`), reviewDocText(round, colour)); + writeFileSync(join(work, 'docs', 'reviews', 'SPEC-REVIEW-7-r1.md'), '# SPEC-REVIEW-7-r1\n\nВердикт: жёлтый · High: 0\n'); + writeFileSync(join(work, 'docs', 'reviews', 'CODE-REVIEW-70-r1.md'), reviewDocText(1, 'жёлтый')); +}; +const verdictOut = (over = {}) => JSON.stringify({ verdict: 'yellow', high: 0, medium: 1, summary: 's', ...over }); +const decideEnv = (over = {}) => ({ + OUT: verdictOut(), STAGE: 'code', REUSE: 'false', SHIP: 'false', SHIP_RISK: '', MATERIAL: 'a'.repeat(40), VALIDATE_URL: '', + TRACK: 'show', CONFIRMED: 'false', LABELS: 'track:show,S7-code-review', SPENT: '1', LIMIT: '2', CYCLE: '2', BRANCH: 'issue/7-x', ...over, +}); +const routeLines = (stdout) => Object.fromEntries(stdout.split('\n').map((line) => /^([a-z_]+)=(.*)$/.exec(line)).filter(Boolean).map((m) => [m[1], m[2]])); +const LABELS_VIEW = 'issue view 7 --repo o/r --json labels --jq [.labels[].name] | join(",")'; + +test('#726 AC5: шаг решения на настоящем bash — reclassify: ask, S3-spec, комментарий с hp:route и перечнем CODE-REVIEW', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) }); + const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP); + box.labels(['track:show', 'S7-code-review', 'P2']); + const r = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) })); + assert.equal(r.status, 0, r.stderr + r.stdout); + const route = routeLines(r.stdout); + const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md'); + assert.deepEqual(r.calls, [ + LABELS_VIEW, + `issue comment 7 --repo o/r --body-file ${commentPath}`, + `issue edit 7 --repo o/r --add-label ${route.add_labels} --remove-label ${route.remove_labels}`, + ]); + assert.deepEqual([route.add_labels, route.remove_labels, route.kind], ['track:ask', 'track:show', 'reclassify'], 'метки — по выходу скрипта'); + assert.deepEqual({ green: r.output.green, from: r.output.from, to: r.output.to }, { green: 'false', from: 'S7-code-review', to: 'S3-spec' }); + assert.equal(r.comment.split('\n')[0], '**Ревью show: решать есть что — трек повышен до `track:ask`.**'); + assert.match(r.comment, /- `docs\/reviews\/CODE-REVIEW-7-r1\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n/); + assert.doesNotMatch(r.comment, /CODE-REVIEW-70|SPEC-REVIEW/, 'только документы код-ревью этой задачи'); + assert.match(r.comment, /Бюджет код-ревью: 2\/4/); + assert.match(r.comment, /\[Прогон\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/42\)/); + assert.match(r.comment, /\n$/); + assert.match(r.summary, /^- маршрут вердикта \*\*reclassify\*\* \(критерий `undocumented`\) → `S3-spec` · блокирующих циклов этапа code: 2\/4$/m); + // Метки не прочитаны — берутся метки guard. + box.labels(null); + const fallback = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) })); + assert.equal(fallback.status, 0, fallback.stderr); + assert.equal(fallback.calls.at(-1), 'issue edit 7 --repo o/r --add-label track:ask --remove-label track:show'); +}); + +test('#726 AC5: шаг решения на настоящем bash — исчерпание, вопрос владельцу, fix, зелёный и сбой скрипта', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) }); + const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP); + box.labels(['track:show', 'S7-code-review']); + const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md'); + // show, spent 1, fix — review-4 этим же вердиктом, возврат в S6. + const exhausted = box.run(run, decideEnv()); + assert.equal(exhausted.status, 0, exhausted.stderr + exhausted.stdout); + assert.deepEqual(exhausted.calls, [LABELS_VIEW, `issue comment 7 --repo o/r --body-file ${commentPath}`, 'issue edit 7 --repo o/r --add-label review-4']); + assert.match(exhausted.comment, /^Лимит циклов ревью исчерпан: блокирующих циклов 2 из 2 на этапе `code`/); + assert.match(exhausted.comment, /- `docs\/reviews\/CODE-REVIEW-7-r1\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md` — этот заход\n/); + assert.equal(exhausted.output.to, 'S6-in-progress'); + assert.match(exhausted.summary, /· `review-4`$/m); + // Подтверждённый show: blocked и вопрос владельцу; blocked уже стоит — метка не ставится второй раз. + box.comments([]); + const question = box.run(run, decideEnv({ CONFIRMED: 'true', SPENT: '0', CYCLE: '1', OUT: verdictOut({ route: 'reclassify', criterion: 'surfaces' }) })); + assert.equal(question.status, 0, question.stderr); + assert.deepEqual(question.calls.slice(1), [`issue comment 7 --repo o/r --body-file ${commentPath}`, 'issue edit 7 --repo o/r --add-label blocked']); + assert.equal(question.comment.split('\n')[0], '**Ревью show: решать есть что — вопрос владельцу.**'); + assert.equal(question.output.to, 'S6-in-progress'); + box.labels(['track:show', 'S7-code-review', 'blocked']); + const again = box.run(run, decideEnv({ CONFIRMED: 'true', SPENT: '0', CYCLE: '1', OUT: verdictOut({ route: 'reclassify', criterion: 'surfaces' }) })); + assert.deepEqual(again.calls.slice(1), [`issue comment 7 --repo o/r --body-file ${commentPath}`], 'комментарий один, меток не трогает'); + // Обычный fix без исчерпания — ни комментария, ни меток; статус — S6. + box.labels(['track:show', 'S7-code-review']); + const fix = box.run(run, decideEnv({ SPENT: '0', CYCLE: '1' })); + assert.equal(fix.status, 0, fix.stderr); + assert.deepEqual(fix.calls, [LABELS_VIEW]); + assert.deepEqual({ green: fix.output.green, to: fix.output.to }, { green: 'false', to: 'S6-in-progress' }); + // Зелёный — вперёд, как раньше. + const green = box.run(run, decideEnv({ SPENT: '1', OUT: verdictOut({ verdict: 'green', medium: 0, route: 'fix' }) })); + assert.equal(green.status, 0, green.stderr); + assert.deepEqual(green.calls, [LABELS_VIEW]); + assert.deepEqual({ green: green.output.green, from: green.output.from, to: green.output.to }, { green: 'true', from: 'S7-code-review', to: 'S8-merged' }); + // Противоречивый вердикт — шаг падает до комментария и меток: метка не меняется, зовётся владелец. + const broken = box.run(run, decideEnv({ OUT: verdictOut({ verdict: 'green', route: 'reclassify', criterion: 'undocumented' }) })); + assert.notEqual(broken.status, 0); + assert.deepEqual(broken.calls, [LABELS_VIEW]); + assert.equal(broken.output.green, undefined, 'исхода нет'); + assert.match(broken.stderr, /reclassify при зелёном вердикте/); +}); + +test('#726 AC5: шаг трека на настоящем bash — confirmed и заметка маршрута для промпта', async (t) => { + if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; } + const box = trackSandbox(t, { change: touchChange }); + const run = stepRun(readFileSync(WORKFLOW, 'utf8'), TRACK_STEP); + box.comments([]); + const show = box.run(run, trackEnv('track:show,S7-code-review')); + assert.equal(show.status, 0, show.stderr); + assert.equal(show.output.confirmed, 'false'); + assert.equal(show.output.route_note, routeNote({ stage: 'code', track: 'show' })); + box.comments([{ author: { login: 'Matysh' }, body: 'Трек: show — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]); + const confirmed = box.run(run, trackEnv('track:show,S7-code-review')); + assert.equal(confirmed.output.confirmed, 'true'); + assert.match(confirmed.output.route_note, /поставит `blocked`/); + const ask = box.run(run, trackEnv('track:ask,S7-code-review')); + assert.equal(ask.output.route_note, '**Маршрут вердикта (#726):** `route: fix`.'); + const spec = box.run(run, trackEnv('track:show,S4-spec-review', 'spec')); + assert.equal(spec.output.route_note, '**Маршрут вердикта (#726):** `route: fix`.'); +}); diff --git a/test/publish-push-refusal.test.mjs b/test/publish-push-refusal.test.mjs index 45e34acb..5d06c1bc 100644 --- a/test/publish-push-refusal.test.mjs +++ b/test/publish-push-refusal.test.mjs @@ -275,14 +275,14 @@ function taskBranch(box) { git(box.work, 'checkout', '-q', 'dev'); } -function runReviewDoc(box) { +function runReviewDoc(box, out = '{"verdict":"green","high":0}') { const source = join(box.temp, 'review-result', 'review-document.md'); mkdirSync(join(box.temp, 'review-result')); writeFileSync(source, '# Код-ревью #9, раунд 1\n\nВердикт: **зелёный** · High: 0 · Medium: 0\n'); return box.run(REVIEW_DOC_STEP(), { BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source, MATERIAL_SHA: git(box.origin, 'rev-parse', BRANCH), MATERIAL_TREE: git(box.origin, 'rev-parse', `${BRANCH}^{tree}`), - MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: '{"verdict":"green","high":0}', + MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: out, }); } @@ -341,6 +341,24 @@ test('#723 _process.yml на настоящем bash: сдвиг, затем о noisySecretsGone(r); }); +test('#726 AC5 _process.yml на настоящем bash: публикация пишет маршрут вердикта хвостом строки якоря', async (t) => { + if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; } + const { anchorVerdictFrom } = await import('../scripts/review-doc-guard.mjs'); + const anchor = (doc) => doc.split('\n').find((line) => line.startsWith('- Вердикт конвейера:')); + const routed = sandbox(tempRoot(t, 'hp-726-doc-')); + taskBranch(routed); + const r = runReviewDoc(routed, JSON.stringify({ verdict: 'yellow', high: 0, medium: 1, summary: 's', route: 'reclassify', criterion: 'undocumented' })); + assert.equal(r.status, 0, r.stderr + r.stdout); + const doc = git(routed.origin, 'show', `${BRANCH}:${REVIEW_DOC}`); + assert.equal(anchor(doc), '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` (критерий `undocumented`)'); + assert.deepEqual(anchorVerdictFrom(doc), { verdict: 'yellow', high: 0 }); + // Вердикт без route (до #726) — прежняя строка. + const plain = sandbox(tempRoot(t, 'hp-726-doc-')); + taskBranch(plain); + assert.equal(runReviewDoc(plain).status, 0); + assert.equal(anchor(git(plain.origin, 'show', `${BRANCH}:${REVIEW_DOC}`)), '- Вердикт конвейера: `green` · High 0'); +}); + // ---------- AC3 и разбор: тексты — из кода, не из run ---------- test('#723 AC3: в run обоих шагов нет многострочного текста и heredoc; отказ разбирает код слияния', () => { diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 94d180c5..74a5bfe1 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -578,6 +578,54 @@ test('изменённое дерево, жёлтый вердикт, High>0 и assert.equal(reusableGreenVerdict([], () => false), null); }); +// #726: маршрут вердикта — хвостом строки якоря; прежний разбор её читает. +test('#726 AC6: якорь с маршрутом — хвост строки; anchorVerdictFrom и reusableGreenVerdict как на старом', () => { + const line = (block) => block.split('\n').find((l) => l.startsWith('- Вердикт конвейера:')); + const base = { sha: 'c'.repeat(40), tree: TREE_A, branch: 'issue/7-x', verdict: 'yellow', high: 0 }; + assert.equal(line(materialAnchorBlock(base)), '- Вердикт конвейера: `yellow` · High 0', 'без route — прежняя строка'); + assert.equal(line(materialAnchorBlock({ ...base, route: 'reclassify', criterion: 'undocumented' })), + '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` (критерий `undocumented`)'); + assert.equal(line(materialAnchorBlock({ ...base, route: 'fix' })), '- Вердикт конвейера: `yellow` · High 0 · маршрут `fix`'); + // Недоверенные значения в машинный блок не попадают: route вне словаря, criterion не по формату. + assert.equal(line(materialAnchorBlock({ ...base, route: 'merge', criterion: 'undocumented' })), '- Вердикт конвейера: `yellow` · High 0'); + for (const criterion of ['q`', 'x -->', 'Undocumented', '', '-a']) { + assert.equal(line(materialAnchorBlock({ ...base, route: 'reclassify', criterion })), + '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify`', JSON.stringify(criterion)); + } + // anchorVerdictFrom читает старую и новую строку одинаково. + for (const verdict of ['green', 'yellow', 'red']) { + for (const high of [0, 2]) { + const old = materialAnchorBlock({ ...base, verdict, high }); + const routed = materialAnchorBlock({ ...base, verdict, high, route: 'reclassify', criterion: 'surfaces' }); + assert.deepEqual(anchorVerdictFrom(routed), anchorVerdictFrom(old)); + assert.deepEqual(anchorVerdictFrom(routed), { verdict, high }); + } + } + // reusableGreenVerdict на новом якоре — как на старом. + const green = (anchors) => docWith('CODE-REVIEW-7-r2.md', { tree: TREE_A, verdict: 'green', high: 0, ...anchors }); + const expected = { doc: 'CODE-REVIEW-7-r2.md', round: 2, tree: TREE_A, verdict: 'green' }; + assert.deepEqual(reusableGreenVerdict([green({})], () => false), expected); + assert.deepEqual(reusableGreenVerdict([green({ route: 'fix' })], () => false), expected); + assert.equal(reusableGreenVerdict([green({ route: 'fix' })], () => true), null, 'дерево отличается — как раньше'); + assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-7-r2.md', { tree: TREE_A, verdict: 'yellow', high: 0, route: 'reclassify', criterion: 'undocumented' })], () => false), null); +}); + +test('#726 AC6: CLI --anchor дописывает маршрут из --route и --criterion', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'hp-726-anchor-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const doc = join(dir, 'CODE-REVIEW-7-r1.md'); + const anchor = (...flags) => { + writeFileSync(doc, '# CODE-REVIEW-7-r1\n'); + const r = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/review-doc-guard.mjs', import.meta.url)), + `--anchor=${doc}`, `--sha=${'c'.repeat(40)}`, `--tree=${TREE_A}`, '--branch=issue/7-x', '--verdict=yellow', '--high=0', ...flags], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + return readFileSync(doc, 'utf8').split('\n').find((l) => l.startsWith('- Вердикт конвейера:')); + }; + assert.equal(anchor('--route=reclassify', '--criterion=undocumented'), '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` (критерий `undocumented`)'); + assert.equal(anchor('--route=', '--criterion='), '- Вердикт конвейера: `yellow` · High 0', 'пустые флаги — вердикт до #726'); + assert.equal(anchor(), '- Вердикт конвейера: `yellow` · High 0'); +}); + test('конвейер: посторонняя метка не входит в concurrency, guard читает текущие метки (#499)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); // Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7. diff --git a/test/review-result-gate.test.mjs b/test/review-result-gate.test.mjs index 2fcb2c69..a861530c 100644 --- a/test/review-result-gate.test.mjs +++ b/test/review-result-gate.test.mjs @@ -8,7 +8,7 @@ import test from 'node:test'; import { readFileSync } from 'node:fs'; import { - reviewResultProblems, parseManifest, sha256, REQUIRED_FILES, PASSPORT_FIELDS, + reviewResultProblems, parseManifest, sha256, REQUIRED_FILES, PASSPORT_FIELDS, ROUTES, verdictRoute, } from '../scripts/review-result-gate.mjs'; const EXPECTED = Object.fromEntries(PASSPORT_FIELDS.map((f) => [f, `значение-${f}`])); @@ -144,3 +144,31 @@ test('#556: integrate пропускает artifact только через ге // Публикация читает вердикт только после гейта. assert.ok(step.indexOf('review-result-gate.mjs') < step.indexOf('structured_output')); }); + +// #726: маршрут вердикта на границе доверия. Смысл criterion судит reviewRoute, +// граница — только словарь route и противоречие с зелёным вердиктом. +test('#726 AC1: route — нет → fix, словарь, green + reclassify — отказ, criterion любой строкой', () => { + assert.deepEqual(ROUTES, ['fix', 'reclassify']); + // route нет (вердикты до #726, модель не заполнила) — принято и читается как fix. + assert.deepEqual(reviewResultProblems(fixture()), []); + assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route: null } })), [], 'null — не заполнено'); + assert.equal(verdictRoute({ verdict: 'yellow' }), 'fix'); + assert.equal(verdictRoute({ verdict: 'yellow', route: null }), 'fix'); + for (const [verdict, route] of [['yellow', 'fix'], ['red', 'fix'], ['green', 'fix'], ['yellow', 'reclassify'], ['red', 'reclassify']]) { + assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict, route } })), [], `${verdict} + ${route}`); + assert.equal(verdictRoute({ verdict, route }), route); + } + // Вне словаря — отказ. + for (const route of ['merge', 'Fix', '', 42, true, {}, ['fix']]) { + const problems = reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route } })); + assert.ok(problems.some((p) => p.startsWith('route вне словаря')), JSON.stringify(route)); + assert.equal(verdictRoute({ route }), null); + } + // Зелёный вместе с reclassify — противоречивый результат, fail-closed. + const green = reviewResultProblems(fixture({ verdict: { verdict: 'green', route: 'reclassify', criterion: 'undocumented' } })); + assert.ok(green.some((p) => p.startsWith('reclassify при зелёном вердикте')), green.join('; ')); + // criterion любой строкой проходит границу — его смысл судит маршрут (К2). + for (const criterion of ['undocumented', 'vibes', '', 'q` --> ']) { + assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route: 'reclassify', criterion } })), [], criterion); + } +}); diff --git a/test/wait-verdict.test.mjs b/test/wait-verdict.test.mjs index 95bb083e..9e985edc 100644 --- a/test/wait-verdict.test.mjs +++ b/test/wait-verdict.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { decide, reviewRequestFromEvents, stateOf, waitForVerdict } from '../scripts/wait-verdict.mjs'; +import { reviewRoute, routeComment } from '../scripts/process-track.mjs'; // #496: ожидание детерминировано — одинаковое состояние молчит, смена метки и // события конвейера доставляются один раз, ничего не пишется. @@ -124,3 +125,47 @@ test('новые outcome и owner blocker текущего раунда не с assert.equal(verdict.code, 0); assert.ok(verdict.lines.some((line) => line.includes('S7-code-review → S8-merged'))); }); + +// #726: тексты — те, что пишет шаг решения по вердикту, а не их копии. +const SHOW = { stage: 'code', track: 'show', limit: 2, verdict: 'yellow', high: 0 }; +const routeBody = (over, cycle = '1') => routeComment({ decision: reviewRoute({ ...SHOW, ...over }), num: '7', cycle, branch: 'issue/7-x', spent: over.spent ?? 0 }); + +test('#726 AC7: reclassify доставляется автору видом reclassify — про ТЗ и S5', () => { + const body = routeBody({ spent: 0, route: 'reclassify', criterion: 'undocumented' }); + const comment = { id: 'r', createdAt: '2026-10-01T10:05:00Z', body }; + const state = stateOf(snap(['S3-spec', 'track:ask'], [comment])); + assert.equal(state.lastEvent.kind, 'reclassify'); + assert.match(state.lastEvent.text, /трек повышен до ask: полное ТЗ в теле issue, код класса A не пушить до S5/); + // Метка сменилась тем же прогоном — вердикт (0) и текст маршрута в строках. + const moved = decide(stateOf(snap(['S7-code-review', 'track:show'])), state); + assert.equal(moved.code, 0); + assert.ok(moved.lines.some((line) => line.includes('полное ТЗ в теле issue'))); + // Комментарий раньше метки — событие доставлено само (3). + const early = decide(stateOf(snap(['S7-code-review'])), stateOf(snap(['S7-code-review'], [comment]))); + assert.equal(early.code, 3); +}); + +test('#726 AC7: вопрос владельцу с blocked доставлен, опрос остановлен; исчерпание — прежний код 3', async () => { + const question = { id: 'q', createdAt: '2', body: routeBody({ spent: 0, route: 'reclassify', criterion: 'surfaces', confirmed: true }) }; + assert.equal(stateOf(snap(['S6-in-progress'], [question])).lastEvent.kind, 'owner-question'); + const states = [snap(['S7-code-review']), snap(['S6-in-progress', 'blocked'], [question]), snap(['S6-in-progress', 'blocked'], [question])]; + let i = 0; const lines = []; let slept = 0; + const code = await waitForVerdict({ + readSnapshot: async () => states[Math.min(i++, states.length - 1)], intervalMs: 1, maxTicks: 10, + sleep: async () => { slept++; }, log: (line) => lines.push(line), + }); + assert.equal(code, 3); + assert.equal(slept, 1, 'опрос остановлен на первом же изменении'); + assert.ok(lines.some((line) => line.includes('ждёт владельца'))); + assert.ok(lines.some((line) => line.includes('blocked: задача ждёт владельца'))); + // Вердикт, исчерпавший бюджет: review-4 и прежний префикс — код 3, вид exhausted. + const exhausted = { id: 'e', createdAt: '3', body: routeBody({ spent: 1 }, '2') }; + const next = stateOf(snap(['S6-in-progress', 'review-4'], [exhausted])); + assert.equal(next.lastEvent.kind, 'exhausted'); + const d = decide(stateOf(snap(['S7-code-review'])), next); + assert.equal(d.code, 3); + assert.ok(d.lines.some((line) => line.includes('лимит циклов исчерпан'))); + // Исчерпание вместе с вопросом владельцу — одним комментарием; решает владелец, вид exhausted. + const both = { id: 'b', createdAt: '4', body: routeBody({ spent: 1, route: 'reclassify', criterion: 'surfaces', confirmed: true }, '2') }; + assert.equal(stateOf(snap(['S6-in-progress', 'review-4', 'blocked'], [both])).lastEvent.kind, 'exhausted'); +});