diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 4bf84bd7..de9b53ba 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -351,20 +351,32 @@ export function checkReviewDocLimit(files) { if (!files.length) { return [{ level: 'warn', rule: 7, sha: '-', msg: 'документов ревью не найдено — проверка 7 пропущена' }]; } - const byIssue = new Map(); + // Счётчик ведётся ОТДЕЛЬНО по виду ревью (#395). Порог описывает бюджет + // заходов одного этапа: «четыре цикла плюс два ребейза» — а ревью ТЗ и + // ревью кода это два разных этапа со своими бюджетами (§4). Общая корзина + // наказывала задачу за то, что она честно прошла оба: у #42 набралось + // 4 spec + 3 code = 7 документов при 4 и 3 заходах по видам, и зелёный + // вердикт r5 три прогона подряд не мог опубликовать свой артефакт. + const byKindAndIssue = new Map(); for (const f of files) { - const m = f.match(/-(\d+)-r(\d+)\.md$/) || f.match(/(\d+).*-r(\d+)\.md$/); + const m = f.match(/(CODE|SPEC)-REVIEW-(\d+)-r(\d+)\.md$/) + || f.match(/-(\d+)-r(\d+)\.md$/) + || f.match(/(\d+).*-r(\d+)\.md$/); if (!m) continue; - const arr = byIssue.get(m[1]) ?? []; - arr.push(Number(m[2])); - byIssue.set(m[1], arr); + const [kind, nn, round] = m.length === 4 ? [m[1], m[2], m[3]] : ['?', m[1], m[2]]; + const key = `${kind}\u0000${nn}`; + const arr = byKindAndIssue.get(key) ?? []; + arr.push(Number(round)); + byKindAndIssue.set(key, arr); } const out = []; - for (const [nn, rounds] of byIssue) { + for (const [key, rounds] of byKindAndIssue) { if (Math.max(...rounds) > REVIEW_DOC_LIMIT || rounds.length > REVIEW_DOC_LIMIT) { + const [kind, nn] = key.split('\u0000'); + const label = kind === '?' ? 'ревью' : `${kind}-REVIEW`; out.push({ level: 'fail', rule: 7, sha: '-', - msg: `issue #${nn}: документов ревью ${rounds.length}, максимум r${Math.max(...rounds)} — больше ${REVIEW_DOC_LIMIT} заходов на один issue`, + msg: `issue #${nn}: документов ${label} ${rounds.length}, максимум r${Math.max(...rounds)} — больше ${REVIEW_DOC_LIMIT} заходов одного вида на один issue`, }); } } diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 5e7c7191..35940808 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -215,6 +215,29 @@ test('the review document count is per issue and per stage', () => { assert.deepEqual(rules(checkReviewDocLimit(['CODE-REVIEW-issue-068-2026-08-12.md'])), []); }); +test('spec and code reviews are counted apart (#395)', () => { + // Порог описывает бюджет заходов ОДНОГО этапа («четыре цикла плюс два + // ребейза»), а ревью ТЗ и ревью кода — два разных этапа со своими + // бюджетами (§4). Общая корзина наказывала задачу за то, что она честно + // прошла оба: ровно эта раскладка у #42 — 4 spec + 3 code — блокировала + // публикацию УЖЕ вынесенного зелёного вердикта три прогона подряд. + const real = [ + 'SPEC-REVIEW-42-r1.md', 'SPEC-REVIEW-42-r2.md', + 'SPEC-REVIEW-42-r3.md', 'SPEC-REVIEW-42-r4.md', + 'CODE-REVIEW-42-r1.md', 'CODE-REVIEW-42-r2.md', 'CODE-REVIEW-42-r5.md', + ]; + assert.deepEqual(rules(checkReviewDocLimit(real)), []); + // Ослабления нет: внутри вида порог прежний, и вид назван в сообщении. + const sevenCode = Array.from({ length: 7 }, (_, i) => `CODE-REVIEW-42-r${i + 1}.md`); + const failed = checkReviewDocLimit([...sevenCode, 'SPEC-REVIEW-42-r1.md']); + assert.deepEqual(rules(failed), [7]); + assert.match(failed[0].msg, /CODE-REVIEW/); + assert.ok(!failed[0].msg.includes('SPEC'), 'жалоба адресна: spec-документы не в счёте'); + // И симметрично для ревью ТЗ. + const sevenSpec = Array.from({ length: 7 }, (_, i) => `SPEC-REVIEW-42-r${i + 1}.md`); + assert.match(checkReviewDocLimit(sevenSpec)[0].msg, /SPEC-REVIEW/); +}); + test('only class A/B commits are held to the issue status', () => { // Документ ревью ложится в ветку, пока задача в S4-spec-review или // S7-code-review: рабочего статуса в этот момент нет, и спрашивать его нельзя.