mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
ci: считать раунды ревью по опубликованным документам
User-Visible: no Issue: #454
This commit is contained in:
@@ -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, а не только в логе прогона.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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/<MARKER>-<NUM>-r<N>.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<N> нечисловой, раунд в счёт не попал');
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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, 'жёлтый вердикт цикла израсходован, хотя проза его не назвала');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user