diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index c317b76e..6fcdd301 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -43,9 +43,19 @@ jobs: spent: ${{ steps.decide.outputs.spent }} limit: ${{ steps.decide.outputs.limit }} steps: + # Мелкий checkout: guard остаётся лёгким, но ему нужен + # scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что + # inline-shell не покрывается тестами (#454). Node на раннере + # предустановлен, setup-node не нужен. + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + ref: dev + persist-credentials: false - id: decide env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} LABEL: ${{ github.event.label.name }} BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }} EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }} @@ -90,9 +100,23 @@ jobs: # имени документа в теле комментария; документа нет — вердикт не # посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы # работу досрочно: из двух ошибок выбрана обратимая. + # + # Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ + # ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария, + # только если ревьюер сам назвал имя файла, — то есть прежний счёт + # зависел от формулировки. На #449 первый спек-вердикт файла не + # назвал, заход r2 получил номер r1, и документ второго раунда лёг + # ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна + # ровно здесь: номер захода входит в имя файла, и повтор номера — не + # лишний заход, а потеря артефакта. + # + # Поэтому рядом встаёт второй источник — опубликованные документы: + # их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся + # МАКСИМУМ двух источников: недосчёт возможен только при отказе + # обоих, перерасчёт невозможен по построению. attempt=1; spent=0; spent_list="" if [ -n "$stage" ]; then - comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments) + comments=$(gh issue view "$NUM" --repo "$REPO" --json comments) of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]" # Блокирующим считается вердикт, у которого в строке вердикта стоит # «жёлтый» или «красный». Регистр и окружение слова не важны. @@ -100,6 +124,41 @@ jobs: attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 )) spent=$(printf '%s' "$comments" | jq -r "$blocking | length") spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")") + + # Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту. + # Её нет у задач, размеченных до появления конвейера; тогда счёт по + # файлам даёт ноль и работает страховка по комментариям. + branch=""; newest="" + for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \ + --jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do + date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true) + if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then + newest="$date"; branch="$ref" + fi + done + target="${branch:-dev}" + names=$(mktemp); docs=$(mktemp -d) + if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \ + > "$names" 2>/dev/null; then + : > "$names" + echo "::warning::список docs/reviews на $target не получен — счёт по файлам отключён" + fi + # Каталог перечисляется одним ответом до 1000 записей; за этой + # границей ответ молча обрежется, и счёт по файлам занизится. + if [ "$(grep -c . "$names")" -ge 1000 ]; then + echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен" + fi + # Тела нужны только своим документам этапа: их единицы. + for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do + gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \ + -H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name" + done + counters=$(node scripts/review-doc-guard.mjs --counters \ + --marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \ + --comment-attempt="$attempt" --comment-spent="$spent") + attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p') + spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p') + echo "ветка материала: ${branch:-нет, читался dev}" fi # Отказ обязан быть виден в issue, а не только в логе прогона. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index fe1a6e80..1fd23d76 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -2439,6 +2439,41 @@ const MUTANT_DEFINITIONS = [ replace: ' if (true) return null;', }], }, + { + id: 'review-round-counts-files-not-max', + guard: 'node --test --test-name-pattern="от максимума номеров" test/review-doc-guard.test.mjs', + because: 'counting how many round documents exist instead of taking the highest number ' + + 'hands out an occupied file name whenever the numbering has a hole — and a hole is ' + + 'exactly what the collision this fixes leaves behind (#454)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: ' return known.length ? Math.max(...known) + 1 : 1;', + replace: ' return known.length + 1;', + }], + }, + { + id: 'review-round-drops-file-source', + guard: 'node --test --test-name-pattern="прожитая уже с исправлением|момент коллизии" ' + + 'test/review-doc-guard.test.mjs', + because: 'without the published documents the counter is back to reading prose, and a ' + + 'verdict that never named its file silently reuses the round number (#454, #449)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: ' const attemptFiles = attemptFromRounds(rounds);', + replace: ' const attemptFiles = 1;', + }], + }, + { + id: 'review-round-drops-comment-insurance', + guard: 'node --test --test-name-pattern="отказ публикации" test/review-doc-guard.test.mjs', + because: 'the publish step can fail after the verdict is already posted; trusting only ' + + 'the documents then loses a spent cycle and hands out a used round number (#454)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: ' attempt: Math.max(attemptFiles, attemptComments),', + replace: ' attempt: attemptFiles,', + }], + }, { id: 'review-doc-guard-matches-by-substring', guard: 'node --test --test-name-pattern="соседний каталог" test/review-doc-guard.test.mjs', diff --git a/scripts/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index 67e0f08d..3c06b226 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -306,10 +306,224 @@ export function withMaterialAnchors(text, anchors) { return `${head}\n\n---\n\n${materialAnchorBlock(anchors)}`; } +/** + * Счёт раундов ревью по ОПУБЛИКОВАННЫМ АРТЕФАКТАМ, а не по прозе вердикта (#454). + * + * Что было. `guard` определял номер захода и расход бюджета §4, считая + * комментарии issue, которые содержат и слово «Вердикт:», и буквальную + * подстроку маркера этапа (`SPEC-REVIEW`/`CODE-REVIEW`). Маркер попадал в тело + * только если ревьюер сам называл имя файла — то есть счёт зависел от + * формулировки. На #449 первый спек-вердикт имени файла не назвал, заход r2 + * получил номер r1, и шаг публикации записал документ второго раунда поверх + * документа первого: `SPEC-REVIEW-449-r1.md` имеет две ревизии, и жёлтый + * вердикт первого раунда утрачен безвозвратно. + * + * Почему недосчёт оказался дороже, чем считалось. Комментарий в `process.yml` + * называл его обратимой ошибкой: «недосчёт даёт лишний заход». Это верно для + * бюджета §4 и неверно для имени документа — номер захода входит в путь + * `docs/reviews/--r.md`, поэтому повтор номера не «лишний + * заход», а потеря артефакта. + * + * Источник истины поэтому меняется: раунд существовал, если существует его + * документ. Имя файла несёт и этап, и номер, и никем не сочиняется — его + * собирает сам конвейер. + */ + +/** Маркер этапа в имени документа. */ +export const REVIEW_STAGE_MARKERS = { spec: 'SPEC-REVIEW', code: 'CODE-REVIEW' }; + +/** + * Номера раундов, для которых на ветке лежит документ. + * + * Возвращает и `skipped` — имена, похожие на документ этапа, но с нечисловым + * суффиксом. Молча их игнорировать нельзя: такое имя означает либо опечатку + * ревьюера, либо изменение формата, и в обоих случаях счёт занижается. + */ +export function reviewRoundsFromFiles(names, marker, num) { + if (!/^[A-Z-]+$/.test(String(marker || '')) || !/^[0-9]+$/.test(String(num || ''))) { + return { rounds: [], skipped: [] }; + } + const exact = new RegExp('^(?:.*/)?' + marker + '-' + num + '-r([0-9]+)\\.md$'); + const loose = new RegExp('^(?:.*/)?' + marker + '-' + num + '-r(.+)\\.md$'); + const rounds = []; + const skipped = []; + for (const raw of names || []) { + const name = String(raw).trim(); + if (!name) continue; + const hit = exact.exec(name); + if (hit) { rounds.push(Number(hit[1])); continue; } + if (loose.test(name)) skipped.push(name); + } + return { rounds: [...new Set(rounds)].sort((a, b) => a - b), skipped }; +} + +/** + * Номер следующего захода: `max(r) + 1`, а НЕ `count + 1`. + * + * Разница видна ровно там, где дефект уже сработал. У #449 после коллизии + * файлов два, `r1` и `r2`, и оба счёта дали бы `r3`. Но при дыре в нумерации + * (`r1`, `r3` — а дыру оставляет как раз коллизия) счёт по количеству выдал бы + * `r3`, то есть ЗАНЯТОЕ имя, и следующий документ затёр бы существующий. + * Максимум занятого номера — единственная величина, которая гарантирует + * свободное имя. + */ +export function attemptFromRounds(rounds) { + const known = (rounds || []).filter((value) => Number.isFinite(value)); + return known.length ? Math.max(...known) + 1 : 1; +} + +/** Строка, ОБЪЯВЛЯЮЩАЯ вердикт (а не упоминающая слово). */ +const VERDICT_DECLARATION = /^[>\s]*(?:[-*+]\s*)?\*{0,2}Вердикт[:*\s]/; +/** Заголовок раздела вердикта: «## Вердикт», «## 6. Вердикт». */ +// `\b` здесь не работает: он ASCII-словесный, а «т» кириллическая, и граница +// после неё не находится. Хвост ограничивается явно. +const VERDICT_SECTION = /^#{1,6}[\s\d.)]*Вердикт(?![\wА-Яа-яЁё])/; +const BLOCKING_COLOUR = /(жёлт|желт|красн)/i; +const VERDICT_COLOUR = /(жёлт|желт|красн|зелён|зелен)/i; + +/** + * Строка вердикта документа, либо `null`. + * + * Правило нарочно СТРОГОЕ и односторонне осторожное: пропустить вердикт не + * страшно — страховка максимумом доберёт его по комментариям, — а лишний + * блокирующий цикл ошибка необратимая: он останавливает работу по §4. + * Поэтому: + * + * - строка обязана НАЧИНАТЬ объявление, а не содержать слово. Замерено по + * корпусу 629 опубликованных документов: свободное упоминание встречается и + * выглядит опасно — `CODE-REVIEW-230-r2.md` цитирует жёлтый вердикт ПРОШЛОГО + * раунда («Комментарий с вердиктом r1 … «Вердикт: жёлтый …»»), а + * `SPEC-REVIEW-449-r2.md` разбирает подстроку `Вердикт:` как предмет задачи. + * Свободный поиск засчитал бы обоим лишний блокирующий цикл; + * - текст внутри тройных кавычек игнорируется — кроме блока, стоящего прямо + * под заголовком раздела «Вердикт»: там конвейер печатает точную копию + * комментария (`CODE-REVIEW-292-r1.md`), и это объявление, а не цитата. + */ +export function verdictDeclaration(text) { + const lines = String(text ?? '').split('\n'); + let fenced = false; + let inSection = false; + let fencedHit = null; + for (const line of lines) { + if (/^\s*```/.test(line)) { fenced = !fenced; continue; } + if (!fenced && VERDICT_SECTION.test(line)) { inSection = true; continue; } + if (!fenced && /^#{1,6}\s/.test(line)) inSection = false; + if (!VERDICT_DECLARATION.test(line) || !VERDICT_COLOUR.test(line)) continue; + if (!fenced) return line; + if (inSection && !fencedHit) fencedHit = line; + } + return fencedHit; +} + +/** Цикл израсходован, если вердикт вернул работу автору (#227). */ +export function isBlockingVerdict(line) { + return Boolean(line) && BLOCKING_COLOUR.test(line); +} + +/** + * Блокирующие раунды по документам: `{ blocking, unread }`. + * + * `unread` — документы, в которых объявления вердикта нет. Их около половины + * корпуса: формат отчёта исторически свободный. Это не дефект счёта, а его + * граница, и она обязана быть видимой в логе прогона — по такому документу + * цикл добирается только страховкой. + */ +export function blockingFromDocs(docs) { + const blocking = []; + const unread = []; + for (const doc of docs || []) { + const line = verdictDeclaration(doc && doc.text); + if (!line) { unread.push((doc && doc.name) || '(без имени)'); continue; } + if (isBlockingVerdict(line)) blocking.push((doc && doc.name) || '(без имени)'); + } + return { blocking, unread }; +} + +/** + * Итоговые счётчики: максимум двух независимых источников. + * + * Максимум, а не сумма и не выбор одного. Шаг публикации может упасть уже + * после того, как вердикт опубликован комментарием, — тогда документа нет, а + * цикл израсходован. И наоборот, комментарий может не назвать файл — тогда + * есть документ, а прозы нет. Недосчёт возможен только при отказе ОБОИХ + * источников; перерасчёт невозможен по построению. + */ +export function reviewCounters({ rounds = [], docs = [], comments = {} } = {}) { + const attemptFiles = attemptFromRounds(rounds); + const { blocking, unread } = blockingFromDocs(docs); + const spentFiles = blocking.length; + const attemptComments = Number.isFinite(Number(comments.attempt)) ? Number(comments.attempt) : 1; + const spentComments = Number.isFinite(Number(comments.spent)) ? Number(comments.spent) : 0; + return { + attempt: Math.max(attemptFiles, attemptComments), + spent: Math.max(spentFiles, spentComments), + attemptFiles, + spentFiles, + attemptComments, + spentComments, + blocking, + unread, + }; +} + const invokedDirectly = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href; if (invokedDirectly) { const argv = process.argv.slice(2); + // Режим счёта раундов (#454): на входе список имён и тела документов, + // на выходе `attempt`/`spent` в формате GITHUB_OUTPUT. + // + // Сбор данных остаётся в workflow (там есть `gh` и токен), решение — здесь: + // счёт обязан быть покрыт тестами, а inline-shell тестами не покрывается. + if (argv.includes('--counters')) { + const value = (name, fallback = '') => { + const found = argv.find((item) => item.startsWith(`--${name}=`)); + return found ? found.slice(name.length + 3) : fallback; + }; + const marker = value('marker'); + const num = value('num'); + const namesFile = value('names'); + const docsDir = value('docs'); + let names = []; + try { + names = readFileSync(namesFile, 'utf8').split('\n'); + } catch (error) { + console.error(`::warning::список документов не прочитан (${error.code || error.message})` + + ' — счёт по файлам отключён, работает страховка по комментариям'); + } + const { rounds, skipped } = reviewRoundsFromFiles(names, marker, num); + for (const name of skipped) { + console.error(`::warning::документ ${name} не даёт номера раунда:` + + ' суффикс -r нечисловой, раунд в счёт не попал'); + } + const docs = []; + for (const round of rounds) { + const name = `${marker}-${num}-r${round}.md`; + try { + docs.push({ name, text: readFileSync(`${docsDir}/${name}`, 'utf8') }); + } catch (error) { + console.error(`::warning::документ ${name} не прочитан` + + ` (${error.code || error.message}) — цикл по нему не засчитан`); + } + } + const counters = reviewCounters({ + rounds, + docs, + comments: { attempt: Number(value('comment-attempt', '1')), spent: Number(value('comment-spent', '0')) }, + }); + if (counters.unread.length) { + console.error(`::warning::вердикт не объявлен машиночитаемой строкой в:` + + ` ${counters.unread.join(', ')} — цикл по этим документам считается` + + ' только по комментариям'); + } + console.error(`::notice::раунды по файлам ${rounds.length ? rounds.join(',') : '—'};` + + ` заход: файлы ${counters.attemptFiles}, комментарии ${counters.attemptComments};` + + ` циклы: файлы ${counters.spentFiles}, комментарии ${counters.spentComments}`); + console.log(`attempt=${counters.attempt}`); + console.log(`spent=${counters.spent}`); + process.exit(0); + } + // Режим дописывания якорей (#414): конвейер снял их при чтении материала. const anchorArg = argv.find((item) => item.startsWith('--anchor=')); if (anchorArg) { diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 509819ef..3e7058b6 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; import { ANCHOR_MARKER, REVIEW_DOC_ALLOWLIST, anchorLiveness, REVIEW_HEADER_LINES, citedMaterialShas, danglingMaterialRefusal, materialAnchorBlock, materialAnchorsFrom, parseSpecList, pathsOutsideAllowlist, reviewDocPushRefusal, withMaterialAnchors, + attemptFromRounds, blockingFromDocs, isBlockingVerdict, reviewCounters, reviewRoundsFromFiles, verdictDeclaration, } from '../scripts/review-doc-guard.mjs'; // #365. 28.08 шаг публикации ревью-дока запушил в dev коммит bb2919f с тридцатью @@ -274,3 +275,170 @@ test('недостижимый якорь не смягчает отказ на const softened = danglingMaterialRefusal(document, orphaned, REVIEW_HEADER_LINES, alive); assert.ok(softened && softened.warning, 'живой якорь по-прежнему смягчает отказ (#414)'); }); + +// #454. Счёт раундов по опубликованным артефактам вместо прозы вердикта. +// +// Дефект стоил артефакта: на #449 первый спек-вердикт не назвал имени файла, +// заход r2 получил номер r1, и документ второго раунда лёг поверх документа +// первого. Жёлтый вердикт первого раунда утрачен безвозвратно — этого не +// восстановит ни один источник, и фикстуры ниже это честно фиксируют. + +const D = (name, verdict) => ({ name, text: `# ${name}\n\n## Вердикт\n\n${verdict}\n` }); + +test('заход берётся от максимума номеров, а не от количества (#454 AC1)', () => { + assert.deepEqual( + reviewRoundsFromFiles(['SPEC-REVIEW-449-r1.md', 'SPEC-REVIEW-449-r2.md'], 'SPEC-REVIEW', '449').rounds, + [1, 2], + ); + assert.equal(attemptFromRounds([1, 2]), 3); + // Дыра в нумерации оставлена прошлой коллизией: счёт по количеству выдал бы + // r3 — занятое имя, и следующий документ затёр бы существующий (AC3). + assert.equal(attemptFromRounds([1, 3]), 4); + assert.equal(attemptFromRounds([]), 1); +}); + +test('чужой этап и чужая задача в счёт не идут (#454 AC5, #89)', () => { + const names = [ + 'SPEC-REVIEW-449-r1.md', 'SPEC-REVIEW-449-r2.md', + 'CODE-REVIEW-449-r1.md', 'CODE-REVIEW-449-r2.md', + 'SPEC-REVIEW-44-r9.md', 'SPEC-REVIEW-4490-r7.md', + ]; + assert.deepEqual(reviewRoundsFromFiles(names, 'SPEC-REVIEW', '449').rounds, [1, 2]); + assert.deepEqual(reviewRoundsFromFiles(names, 'CODE-REVIEW', '449').rounds, [1, 2]); +}); + +test('нечисловой суффикс не проглатывается молча (#454)', () => { + const { rounds, skipped } = reviewRoundsFromFiles( + ['SPEC-REVIEW-449-r1.md', 'SPEC-REVIEW-449-rX.md'], 'SPEC-REVIEW', '449', + ); + assert.deepEqual(rounds, [1]); + assert.deepEqual(skipped, ['SPEC-REVIEW-449-rX.md']); +}); + +test('строка вердикта опознаётся, а упоминание — нет (#454 AC2)', () => { + assert.ok(isBlockingVerdict(verdictDeclaration('Вердикт: жёлтый · заход r1'))); + assert.ok(isBlockingVerdict(verdictDeclaration('**Вердикт: красный** · заход r2'))); + assert.ok(isBlockingVerdict(verdictDeclaration('- **Вердикт:** жёлтый'))); + assert.equal(isBlockingVerdict(verdictDeclaration('Вердикт: зелёный · заход r3')), false); + // Цитата чужого вердикта внутри прозы — CODE-REVIEW-230-r2.md цитирует + // жёлтый вердикт ПРОШЛОГО раунда. Свободный поиск засчитал бы лишний цикл. + assert.equal( + verdictDeclaration('Комментарий с вердиктом r1 («Вердикт: жёлтый · заход r1») учтён.'), + null, + ); + // Блок под заголовком раздела — объявление (CODE-REVIEW-292-r1.md). + assert.ok(isBlockingVerdict(verdictDeclaration( + '## Вердикт\n\n```\nВердикт: красный · заход r1\n```\n', + ))); + // Тот же блок вне раздела — цитата, а не объявление. + assert.equal(verdictDeclaration('## Скоуп\n\n```\nВердикт: красный\n```\n'), null); +}); + +test('документ без строки вердикта виден как непрочитанный, а не как зелёный (#454)', () => { + const { blocking, unread } = blockingFromDocs([ + { name: 'SPEC-REVIEW-449-r1.md', text: 'Без High это жёлтый вердикт: ТЗ возвращается автору.' }, + D('SPEC-REVIEW-449-r2.md', 'Вердикт: зелёный · заход r3'), + ]); + assert.deepEqual(blocking, []); + assert.deepEqual(unread, ['SPEC-REVIEW-449-r1.md']); +}); + +test('#449 как есть: заход 3, циклов 1 (#454 AC2)', () => { + // Буквальный слепок сегодняшнего состояния: файла два (в r1 лежит тело + // ВТОРОГО раунда), комментариев три, но маркер несут только два — первый + // вердикт не назвал файла, и это тот самый дефект. + const rounds = reviewRoundsFromFiles( + ['SPEC-REVIEW-449-r1.md', 'SPEC-REVIEW-449-r2.md'], 'SPEC-REVIEW', '449', + ).rounds; + const docs = [ + { name: 'SPEC-REVIEW-449-r1.md', text: '# SPEC-REVIEW — issue #449 · заход r2\n\n## Вердикт\n\nБез High это жёлтый вердикт (PROCESS.md §2.4).\n' }, + { name: 'SPEC-REVIEW-449-r2.md', text: '# SPEC-REVIEW — issue #449 · заход r3\n\n## Вердикт\n\nHigh: 0 · Medium: 0 — зелёное.\n' }, + ]; + const counters = reviewCounters({ rounds, docs, comments: { attempt: 3, spent: 1 } }); + assert.equal(counters.attempt, 3); + assert.equal(counters.spent, 1); + // Жёлтый вердикт ПЕРВОГО раунда невосстановим: его файл перезаписан, его + // комментарий маркера не содержит. Ни один источник его не воскрешает. + assert.equal(counters.spentFiles, 0); + assert.equal(counters.spentComments, 1); +}); + +test('#449, прожитая уже с исправлением: заход 4, циклов 2 (#454 AC2b)', () => { + const names = ['SPEC-REVIEW-449-r1.md', 'SPEC-REVIEW-449-r2.md', 'SPEC-REVIEW-449-r3.md']; + const { rounds } = reviewRoundsFromFiles(names, 'SPEC-REVIEW', '449'); + const docs = [ + D('SPEC-REVIEW-449-r1.md', 'Вердикт: жёлтый · заход r1'), + D('SPEC-REVIEW-449-r2.md', 'Вердикт: жёлтый · заход r2'), + D('SPEC-REVIEW-449-r3.md', 'Вердикт: зелёный · заход r3'), + ]; + // Комментарии те же, что в реальности: первый вердикт маркера не несёт. + const counters = reviewCounters({ rounds, docs, comments: { attempt: 3, spent: 1 } }); + assert.equal(counters.attempt, 4); + assert.equal(counters.spent, 2); +}); + +test('зелёный вердикт цикла не тратит (#454 AC4, #227)', () => { + const docs = [ + D('SPEC-REVIEW-1-r1.md', 'Вердикт: жёлтый · заход r1'), + D('SPEC-REVIEW-1-r2.md', 'Вердикт: зелёный · заход r2'), + D('SPEC-REVIEW-1-r3.md', 'Вердикт: зелёный · заход r3'), + ]; + const counters = reviewCounters({ rounds: [1, 2, 3], docs, comments: { attempt: 4, spent: 1 } }); + assert.equal(counters.attempt, 4); + assert.equal(counters.spent, 1); +}); + +test('отказ публикации не занижает счёт: работает максимум (#454 AC6)', () => { + // Вердикт опубликован комментарием, документ не лёг. Файлов меньше, чем + // раундов, — и именно поэтому берётся максимум, а не счёт по файлам. + const counters = reviewCounters({ + rounds: [1], + docs: [D('SPEC-REVIEW-1-r1.md', 'Вердикт: жёлтый · заход r1')], + comments: { attempt: 3, spent: 2 }, + }); + assert.equal(counters.attempt, 3); + assert.equal(counters.spent, 2); +}); + +test('ветки нет: счёт по файлам ноль, поведение прежнее (#454 AC7)', () => { + const counters = reviewCounters({ rounds: [], docs: [], comments: { attempt: 2, spent: 1 } }); + assert.equal(counters.attempt, 2); + assert.equal(counters.spent, 1); + // И наоборот: недоступны комментарии — счёт живёт на файлах. + const onlyFiles = reviewCounters({ + rounds: [1, 2], + docs: [ + D('SPEC-REVIEW-1-r1.md', 'Вердикт: жёлтый · заход r1'), + D('SPEC-REVIEW-1-r2.md', 'Вердикт: красный · заход r2'), + ], + comments: {}, + }); + assert.equal(onlyFiles.attempt, 3); + assert.equal(onlyFiles.spent, 2); +}); + +test('мусор на входе не роняет счёт (#454 AC7)', () => { + assert.deepEqual(reviewRoundsFromFiles(null, 'SPEC-REVIEW', '449').rounds, []); + assert.deepEqual(reviewRoundsFromFiles(['x'], '', '449').rounds, []); + assert.deepEqual(reviewRoundsFromFiles(['x'], 'SPEC-REVIEW', '').rounds, []); + assert.deepEqual(reviewRoundsFromFiles(['SPEC-REVIEW-449-r1.md'], 'SPEC-REVIEW', '4.9').rounds, []); + const counters = reviewCounters(); + assert.equal(counters.attempt, 1); + assert.equal(counters.spent, 0); +}); + +test('момент коллизии на #449: файлы дали бы свободное имя, проза — занятое (#454)', () => { + // Состояние 14:56, когда заход r2 только начинался. Комментарий первого + // вердикта маркера не нёс, поэтому прежний счёт видел ноль вердиктов этапа и + // выдавал заход r1 — имя, которое уже занято. Документ первого раунда был + // перезаписан ровно здесь. + const beforeFix = { attempt: 1, spent: 0 }; + const counters = reviewCounters({ + rounds: reviewRoundsFromFiles(['SPEC-REVIEW-449-r1.md'], 'SPEC-REVIEW', '449').rounds, + docs: [D('SPEC-REVIEW-449-r1.md', 'Вердикт: жёлтый · заход r1')], + comments: beforeFix, + }); + assert.equal(counters.attemptComments, 1, 'проза видела ноль вердиктов — это и был дефект'); + assert.equal(counters.attempt, 2, 'файл раунда r1 существует, значит следующий заход r2'); + assert.equal(counters.spent, 1, 'жёлтый вердикт цикла израсходован, хотя проза его не назвала'); +});