feat(process): a failed show verdict re-routes to ask without a fresh budget (#726)

A non-green show verdict that found "something to decide" went down the same
path as "fix the code": S6 with a limit of 2. Promoting the task to track:ask
was left to the agent's memory, with no named criterion and no trace, and the
exhausted budget only surfaced on the next S7 - after a fix nobody would read.

The structured verdict now carries `route` (fix | reclassify) and an optional
`criterion` (one of the six show criteria of PROCESS.md section 5). The trust
boundary reads a missing route as fix, rejects one outside the dictionary and
rejects reclassify on a green verdict. `reviewRoute` in process-track.mjs is
the single decision: on a code review of an unconfirmed show it moves the task
to track:ask and S3-spec; on an owner-confirmed show it adds `blocked` and asks
the owner; anywhere else reclassify degrades to fix with a note. The verdict
that spends the last cycle sets review-4 at once; the stage budget is shared
across tracks, so promotion changes the limit (4), not the count.

The "Решение по вердикту" step makes one `process-track.mjs route` call (from
dev, like the track step) and only executes its output: comment from a file,
labels from add/remove lists, status via status-label.mjs as before. The track
step also emits `confirmed` and a `route_note` for the review prompt; the
review document anchor gains a route tail that the old reader still parses;
wait-verdict reports the two new pipeline comments. The guard's own
spent >= limit check stays as the safety net.

Issue: #726
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 03:06:49 +00:00
committed by claude[bot]
parent 56be779ca7
commit d327ec3d93
14 changed files with 996 additions and 60 deletions
+74 -21
View File
@@ -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:
<!-- hp:ship-merge material=$MATERIAL -->
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"
+53 -6
View File
@@ -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: решать
есть что — вопрос владельцу.**», последней — машинная
`<!-- hp:route <reclassify|owner-question> criterion=<id> -->`. Исчерпание
бюджета — первой строкой `Лимит циклов ревью исчерпан`, дальше счёт,
учтённые вердикты и варианты §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) |
+9 -2
View File
@@ -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`)
+14 -6
View File
@@ -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-метки-тяжёлых-проверок-и-прежние-метки)).
+275 -8
View File
@@ -5,6 +5,8 @@
*
* node scripts/process-track.mjs stage --stage=code|spec --labels="a,b" --branch=<имя> \
* --base=origin/dev --head=HEAD --comments=<json> --owner=<login> --out=<dir> [--run-url=<url>]
* node scripts/process-track.mjs route --stage=code|spec --track=<t> --confirmed=true|false --labels="a,b" \
* --verdict=<verdict.json> --spent=N --limit=N --num=NN --cycle=N --branch=<имя> --out=<dir> [--ref=HEAD --run-url=<url>]
* node scripts/process-track.mjs limit --labels="a,b" [--files=<список путей>]
* node scripts/process-track.mjs resolve --labels="a,b" --base=<ref> --head=<ref>
* node scripts/process-track.mjs ship-limits --base=<ref> --head=<ref>
@@ -15,7 +17,15 @@
* слияния ship. Печатает `track=`, `mutants=`, `full=`, `ship=`, `raise=`,
* `basis=`, `risk=`; те же поля и многострочные `risk_note`/`ship_risk` пишет в
* `$GITHUB_OUTPUT`; при `raise=true` кладёт тело комментария в `<out>/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=`;
* тело комментария кладёт в `<out>/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(`<!-- hp:route ${decision.kind} criterion=${id} -->`);
}
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=<b> --base=<ref> --out=<dir> [--head --comments --owner --run-url] | limit --labels=a,b [--files=<file>] | resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
throw new Error('usage: process-track.mjs stage --stage=code|spec --labels=a,b --branch=<b> --base=<ref> --out=<dir> [--head --comments --owner --run-url] | route --stage=code|spec --track=<t> --confirmed=true|false --verdict=<verdict.json> --spent=N --limit=N --num=NN --cycle=N --out=<dir> [--labels --branch --ref --run-url] | limit --labels=a,b [--files=<file>] | resolve --labels=a,b [--base=<ref> --head=<ref>] | ship-limits --base=<ref> [--head=<ref>]');
}
} catch (error) {
console.error(`::error::${error.message}`);
+19 -2
View File
@@ -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');
+34 -5
View File
@@ -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`: «<hex> <имя>». */
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]
+6 -1
View File
@@ -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) — читать комментарий' },
];
+3
View File
@@ -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<N> · блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`'],
['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'],
['4-лимит-циклов-ревью-4', 'Зелёный вердикт цикла не образует'],
['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'],
['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'],
['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'],
],
+367 -6
View File
@@ -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<!-- hp:route reclassify criterion=undocumented -->\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, /<!-- hp:route owner-question criterion=perf-touch -->\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]+<!-- hp:route owner-question criterion=surfaces -->\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` --> <b>' }), num: '7', cycle: '1' });
assert.match(evil, /критерий `q\?\?--\?\?\?b\?` не из списка/);
assert.doesNotMatch(evil, /q`|-->|<b>/);
// Сводка прогона: маршрут и бюджет после вердикта.
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(/<<EOF/g) || []).length, 1);
});
const reviewDocText = (round, colour) => `# 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, /<!-- hp:route reclassify criterion=undocumented -->\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`.');
});
+20 -2
View File
@@ -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; отказ разбирает код слияния', () => {
+48
View File
@@ -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.
+29 -1
View File
@@ -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` --> <b>']) {
assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route: 'reclassify', criterion } })), [], criterion);
}
});
+45
View File
@@ -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');
});