import test from 'node:test'; import assert from 'node:assert/strict'; 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, commentCounters, stageVerdictComments, anchorTreeFrom, anchorVerdictFrom, reusableGreenVerdict, anchorIssueBodyFrom, issueBodyChanged, issueBodyDigest, normalizeIssueBody, } from '../scripts/review-doc-guard.mjs'; // #365. 28.08 шаг публикации ревью-дока запушил в dev коммит bb2919f с тридцатью // файлами вместо одного markdown: откатил отревьюженную реализацию #359, вернул // старые чанки, оставил в dist/ двойной набор. dev держал откаченное дерево три // часа. Сообщение коммита было невинным — «docs: review document for #359», — и // от рутины инцидент отличался только диффом. Релиз собирается из dev. test('чистая публикация проходит (#365 AC1)', () => { assert.equal(reviewDocPushRefusal(['docs/reviews/CODE-REVIEW-359-r1.md']), null); assert.equal(reviewDocPushRefusal([ 'docs/reviews/SPEC-REVIEW-1-r1.md', 'docs/reviews/SPEC-REVIEW-1-r2.md', ]), null); }); test('посторонний путь отменяет пуш и называет файлы (#365 AC2)', () => { const refusal = reviewDocPushRefusal([ 'docs/reviews/CODE-REVIEW-359-r1.md', 'src/houseplan-card.ts', 'dist/houseplan-card.js', ]); assert.match(refusal, /задевает 2 путь\(ей\)/); assert.match(refusal, /dist\/houseplan-card\.js/); assert.match(refusal, /src\/houseplan-card\.ts/); // Причина названа, а не только факт: без неё следующий читатель решит, что // проверка придирается, и снимет её. assert.match(refusal, /bb2919f/); }); test('пустой дифф — тоже отказ, а не тихий успех (#365)', () => { // Публиковать нечего означает, что что-то пошло не так раньше. Прежняя // редакция шага в таком случае выходила с нулём, и вердикт ревью оставался // без артефакта (#171). assert.match(reviewDocPushRefusal([]), /публиковать нечего/); assert.match(reviewDocPushRefusal(['', ' ']), /публиковать нечего/); }); test('соседний каталог с похожим именем не считается разрешённым (#365)', () => { // Сравнение по префиксу каталога со слэшем: docs/reviews-old подстрокой не // притворяется. assert.deepEqual( pathsOutsideAllowlist(['docs/reviews-old/x.md', 'docs/reviews/y.md']), ['docs/reviews-old/x.md'], ); assert.deepEqual(pathsOutsideAllowlist(['docs/reviewsx.md']), ['docs/reviewsx.md']); }); test('allowlist задаётся снаружи и по умолчанию только docs/reviews (#365)', () => { assert.deepEqual(REVIEW_DOC_ALLOWLIST, ['docs/reviews/']); assert.equal(reviewDocPushRefusal(['docs/specs/1.md'], ['docs/specs']), null); assert.match(reviewDocPushRefusal(['docs/specs/1.md']), /docs\/specs\/1\.md/); }); test('шаг публикации в конвейере проверяет и индекс, и то, что уедет (#365 AC4)', () => { const workflow = readFileSync( new URL('../.github/workflows/process.yml', import.meta.url), 'utf8', ); const step = workflow.slice( workflow.indexOf('- name: Опубликовать документ ревью'), workflow.indexOf('- name: Решение по вердикту'), ); assert.ok(step.length > 500, 'шаг публикации не найден'); // Два рубежа: что проиндексировано и что пуш добавит в ветку. Расходились они // именно тогда, когда база оказывалась не той. assert.equal( (step.match(/git diff --cached --name-only \| node scripts\/review-doc-guard\.mjs/g) || []).length, 1, 'индекс проверяется один раз, перед коммитом', ); // Дважды: push делается из двух мест — сразу и после ребейза при гонке. Одна // проверка на два пути означала бы, что второй путь не проверен вовсе, а // именно он срабатывает, когда dev ушёл вперёд — то есть в тех самых // условиях, при которых случился bb2919f. assert.equal( (step.match(/git diff --name-only "origin\/\$target\.\.\.HEAD" \| node scripts\/review-doc-guard\.mjs/g) || []).length, 2, 'диапазон проверяется перед каждым push', ); // Свежая база вместо той, что лежала здесь сорок минут назад. assert.match(step, /git reset -q --hard "origin\/\$target"/); // Форс-пуш запрещён: ветка двигается только вперёд. assert.equal(/--force/.test(step), false, 'в публикации ревью-дока не должно быть force-push'); // Индексируется один путь, а не каталог. assert.match(step, /git add -- "\$doc"/); }); // --- материал раунда обязан быть достижим (#413) ---------------------------- test('SHA из шапки извлекаются, а из прозы — нет (#413)', () => { const doc = [ '# SPEC-REVIEW-403-r2', '', '## Скоуп', '', '- Материал: спец-файл на `HEAD = 83005c3c` (ветка `issue/403-x`,', ' коммит «docs: revise area relocation safety spec»)', '- Ревизия: 2', ].join('\n') + '\n'.repeat(30) + 'Так коммит bb2919f7 откатил dev на три часа.\n'; const cited = citedMaterialShas(doc); assert.deepEqual(cited.map((item) => item.sha), ['83005c3c']); assert.equal(cited[0].line, 5); }); test('не-SHA в шапку не попадают: цвета, sha256, номера (#413)', () => { const doc = [ '- Материал: коммит `cbf5cc1b`, цвет #607d8bff, прогон 20260901,', ' imageSha256 `9119ab87502038f787529f621c39e1e0d01f3bc3b0289051c3791a1886e97a6b`,', ' ссылка sha256-abc1234def', ].join('\n'); assert.deepEqual(citedMaterialShas(doc).map((item) => item.sha), ['cbf5cc1b']); }); test('недостижимый SHA останавливает раунд и объясняет, почему (#413)', () => { const doc = '- Материал: спец-файл на `HEAD = 83005c3c`\n'; const refusal = danglingMaterialRefusal(doc, () => new Map([['83005c3c', null]])); assert.match(refusal, /83005c3c/); assert.match(refusal, /не достижим ни из одной ссылки origin/); // Отказ обязан называть и команду из канона, и способ не повторить: // на #403 ревьюер снял HEAD до ребейза и не сверился перед выводом. assert.match(refusal, /git diff/); assert.match(refusal, /git rev-parse HEAD/); }); test('достижимый SHA раунд не задерживает (#413)', () => { const doc = '- Материал: коммит `cbf5cc1b`\n'; const resolve = () => new Map([['cbf5cc1b', 'refs/remotes/origin/dev']]); assert.equal(danglingMaterialRefusal(doc, resolve), null); }); test('шапка без объявления материала не судится (#413)', () => { // Часть документов материал не объявляет вовсе — по корпусу таких 146 из 555. // Требовать объявление — отдельное решение о каноне, а не дело гейта. assert.equal(danglingMaterialRefusal('# CODE-REVIEW-1-r1\n\nтекст\n', () => new Map()), null); assert.deepEqual(citedMaterialShas('# CODE-REVIEW-1-r1\n\nтекст\n'), []); }); // --- якоря, переживающие ребейз (#414) ------------------------------------- test('блок якорей содержит исполнимые команды, а не описание (#414)', () => { const block = materialAnchorBlock({ sha: '94502d3d67cacf85bdb9f69cd511b342989891fd', tree: '3fc651fcb868eefa28755d01ec2b9377598dcb27', branch: 'issue/403-area-relocation-safety', specs: [{ blob: '56a92e12dedc8fa541537ae5908dc6f1dfab43e8', path: 'docs/specs/403-area-relocation-safety.md', }], }); // Отчёт обязан быть исполняемым: на #403 канонная команда не работала, и // следующий раунд восстанавливал коммит по содержимому диффа руками. assert.match(block, /git log --all --find-object=56a92e12dedc8fa541537ae5908dc6f1dfab43e8/); assert.match(block, /git log --all --format='%H %T' \| grep 3fc651fcb868/); assert.match(block, /ребейз его осиротит/, 'блок обязан объяснять, зачем он нужен'); assert.match(block, /material-anchors: сгенерировано конвейером/); }); test('без ветки задачи блок честно говорит, что якорей нет (#414)', () => { const block = materialAnchorBlock({ branch: '', sha: '', tree: '', specs: [] }); assert.match(block, /Якоря снять не удалось/); }); test('повторная приписка заменяет блок, а не копит его (#414)', () => { const anchors = { sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'dev', specs: [] }; const once = withMaterialAnchors('# отчёт\n\nтекст\n', anchors); const twice = withMaterialAnchors(once, anchors); assert.equal(twice.split(ANCHOR_MARKER).length - 1, 1, 'маркер обязан быть один'); assert.match(twice, /# отчёт/); }); test('список ТЗ разбирается и отсекает мусор (#414)', () => { const parsed = parseSpecList( `${'a'.repeat(40)} docs/specs/403-x.md;короткий docs/specs/y.md;${'b'.repeat(40)} ;`, ); assert.deepEqual(parsed, [{ blob: 'a'.repeat(40), path: 'docs/specs/403-x.md' }]); }); test('осиротевший SHA при живых якорях — предупреждение, не отказ (#414)', () => { const doc = withMaterialAnchors( '- Материал: спец-файл на `HEAD = 83005c3c`\n', { sha: 'c'.repeat(40), tree: 'd'.repeat(40), branch: 'issue/403-x', specs: [] }, ); const verdict = danglingMaterialRefusal( doc, () => new Map([['83005c3c', null]]), REVIEW_HEADER_LINES, () => true, ); assert.ok(verdict.warning, 'раунд воспроизводим — ронять его нечего'); assert.match(verdict.warning, /83005c3c/); assert.match(verdict.warning, /по якорям/); }); test('осиротевший SHA и мёртвые якоря — по-прежнему отказ (#414)', () => { const doc = withMaterialAnchors( '- Материал: спец-файл на `HEAD = 83005c3c`\n', { sha: 'c'.repeat(40), tree: 'd'.repeat(40), branch: 'issue/403-x', specs: [] }, ); const verdict = danglingMaterialRefusal( doc, () => new Map([['83005c3c', null]]), REVIEW_HEADER_LINES, () => false, ); assert.equal(typeof verdict, 'string'); assert.match(verdict, /не достижим ни из одной ссылки origin/); }); // --- #422: живость якоря — достижимость, а не наличие объекта ------------- /** Подставная проба git: описывает мир, а не запускает его. */ const gitProbe = ({ type, reachableTrees = [], blobFound = false, refFound = false }) => (args) => { if (args[0] === 'cat-file') return { status: type ? 0 : 1, stdout: type || '' }; if (args[0] === 'log' && args.includes('--format=%T')) { return { status: 0, stdout: `${reachableTrees.join('\n')}\n` }; } if (args[0] === 'log') return { status: 0, stdout: blobFound ? 'c0ffee\n' : '' }; if (args[0] === 'for-each-ref') return { status: 0, stdout: refFound ? 'refs/remotes/origin/dev\n' : '' }; return { status: 1, stdout: '' }; }; test('якорь-дерево, существующий локально, но недостижимый, живым не считается', () => { const object = 'a'.repeat(40); assert.equal(anchorLiveness(object, gitProbe({ type: 'tree', reachableTrees: ['b'.repeat(40)] })), false); }); test('якорь-дерево, достижимый из origin, считается живым', () => { const object = 'a'.repeat(40); assert.equal(anchorLiveness(object, gitProbe({ type: 'tree', reachableTrees: [object] })), true); }); test('якорь-блоб проверяется поиском по достижимым коммитам', () => { const object = 'c'.repeat(40); assert.equal(anchorLiveness(object, gitProbe({ type: 'blob', blobFound: false })), false); assert.equal(anchorLiveness(object, gitProbe({ type: 'blob', blobFound: true })), true); }); test('якорь-коммит проверяется тем же способом, что и SHA раунда', () => { const object = 'd'.repeat(40); assert.equal(anchorLiveness(object, gitProbe({ type: 'commit', refFound: false })), false); assert.equal(anchorLiveness(object, gitProbe({ type: 'commit', refFound: true })), true); }); test('неизвестный тип объекта и отсутствующий объект живыми не считаются', () => { const object = 'e'.repeat(40); assert.equal(anchorLiveness(object, gitProbe({ type: 'tag' })), false); assert.equal(anchorLiveness(object, gitProbe({ type: '' })), false); }); test('областью поиска служат origin и теги, а не --all', () => { const seen = []; const probe = (args) => { seen.push(args.join(' ')); return args[0] === 'cat-file' ? { status: 0, stdout: 'blob' } : { status: 0, stdout: '' }; }; anchorLiveness('f'.repeat(40), probe); const search = seen.find((line) => line.startsWith('log')); assert.ok(search.includes('--remotes=origin'), search); assert.ok(search.includes('--tags'), search); assert.ok(!search.includes('--all'), 'локальные ветки автора не считаются доказательством'); }); test('недостижимый якорь не смягчает отказ на осиротевшем SHA раунда (#422)', () => { const anchor = 'd'.repeat(40); const document = withMaterialAnchors( '- Материал: спец-файл на `HEAD = 83005c3c`\n', { sha: 'c'.repeat(40), tree: anchor, branch: 'issue/422-x', specs: [] }, ); const orphaned = () => new Map([['83005c3c', null]]); const dead = (object) => anchorLiveness(object, gitProbe({ type: 'tree', reachableTrees: [] })); const alive = (object) => anchorLiveness(object, gitProbe({ type: 'tree', reachableTrees: [anchor] })); const refusal = danglingMaterialRefusal(document, orphaned, REVIEW_HEADER_LINES, dead); assert.equal(typeof refusal, 'string', 'мёртвый якорь обязан оставить жёсткий отказ'); assert.match(refusal, /не достижим ни из одной ссылки origin/); 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, 'жёлтый вердикт цикла израсходован, хотя проза его не назвала'); }); test('guard считает раунды скриптом, а не inline-shell (#454 AC9)', () => { const workflow = readFileSync( new URL('../.github/workflows/process.yml', import.meta.url), 'utf8', ); const step = workflow.slice( workflow.indexOf(' - id: decide'), workflow.indexOf(' review:'), ); assert.ok(step.length > 500, 'шаг decide не найден'); // Решение принимает модуль под тестами. Пока счёт жил строкой jq внутри // workflow, у него не было ни одного теста — и дефект #454 прожил месяцы. assert.match(step, /node scripts\/review-doc-guard\.mjs --counters/); // Оба счётчика читаются из вывода скрипта, а не досчитываются в shell. assert.match(step, /attempt=\$\(printf '%s\\n' "\$counters"/); assert.match(step, /spent=\$\(printf '%s\\n' "\$counters"/); // Скрипт лежит в репозитории, значит guard обязан его выкачать. assert.match(workflow.slice(0, workflow.indexOf(' - id: decide')), /actions\/checkout/); }); test('описание чужого раунда не объявляет вердикт (#454, находка на корпусе)', () => { // CODE-REVIEW-441-r1.md: документ зелёного второго раунда описывает жёлтый // ПЕРВЫЙ раунд строкой-буллитом. Правило, допускавшее слово между «Вердикт» // и двоеточием, добавляло зелёному раунду блокирующий цикл. assert.equal( verdictDeclaration('- Вердикт r1: жёлтый, High 0, Medium 1 в скоупе — красный `check-docs.mjs`'), null, ); assert.equal(verdictDeclaration('- Вердикт предыдущего раунда: красный'), null); // Штатные формы объявления при этом обязаны читаться по-прежнему. assert.ok(verdictDeclaration('Вердикт: жёлтый · заход r1')); assert.ok(verdictDeclaration('**Вердикт: красный** · заход r2')); assert.ok(verdictDeclaration('- **Вердикт:** жёлтый')); assert.ok(verdictDeclaration('- Вердикт: зелёный')); }); // Вторая половина счёта — комментарии. Прежнее правило («в теле есть // «Вердикт:» и есть имя маркера») протекало на прозе, и поймано это было на // самой #454: разбор чужих задач в комментарии-передаче работы содержал слово // CODE-REVIEW, и первый же код-ревью получил заход r3 вместо r1. const C = (body, url = 'https://x/1') => ({ body, url }); test('вердикт этапа опознаётся по документу ЭТОЙ задачи (#454 AC5, #89)', () => { const comments = [ C('Вердикт: жёлтый · заход r1\n\nДокумент: docs/reviews/CODE-REVIEW-454-r1.md'), // Зелёный вердикт СПЕК-этапа, случайно упомянувший чужой документ. C('Вердикт: зелёный · заход r2 — разобрано по аналогии с CODE-REVIEW-441-r1.md' + '\n\nДокумент: docs/reviews/SPEC-REVIEW-454-r2.md'), // Передача работы: слово «Вердикт:» в прозе и имя чужого документа. C('Реализация готова. Строки `Вердикт:` в документе нет; ср. CODE-REVIEW-439-r1.md'), ]; const code = commentCounters(comments, 'CODE-REVIEW', '454'); assert.equal(code.attempt, 2, 'настоящий код-вердикт ровно один'); assert.equal(code.spent, 1); const spec = commentCounters(comments, 'SPEC-REVIEW', '454'); assert.equal(spec.attempt, 2); assert.equal(spec.spent, 0, 'зелёный цикла не тратит'); }); test('чужой номер задачи не засчитывается своим (#454 AC5)', () => { const comments = [C('Вердикт: красный · заход r1\n\nДокумент: docs/reviews/CODE-REVIEW-4540-r1.md')]; assert.equal(commentCounters(comments, 'CODE-REVIEW', '454').attempt, 1); assert.equal(commentCounters(comments, 'CODE-REVIEW', '4540').attempt, 2); }); test('перечень учтённого сходится с числом циклов (#454)', () => { const comments = [ C('Вердикт: жёлтый · заход r1 — CODE-REVIEW-454-r1.md', 'https://x/1'), C('Вердикт: зелёный · заход r2 — CODE-REVIEW-454-r2.md', 'https://x/2'), C('Вердикт: красный · заход r3 — CODE-REVIEW-454-r3.md', 'https://x/3'), ]; const counters = commentCounters(comments, 'CODE-REVIEW', '454'); assert.equal(counters.spent, 2); assert.deepEqual(counters.list, ['https://x/1', 'https://x/3']); }); test('мусор в комментариях не роняет счёт (#454 AC7)', () => { assert.equal(commentCounters(null, 'CODE-REVIEW', '454').attempt, 1); assert.equal(commentCounters([{}, { body: null }, 'строка'], 'CODE-REVIEW', '454').attempt, 1); assert.equal(commentCounters([C('Вердикт: жёлтый CODE-REVIEW-454-r1.md')], '', '454').attempt, 1); assert.deepEqual(stageVerdictComments([C('Вердикт: жёлтый CODE-REVIEW-454-r1.md')], 'CODE-REVIEW', 'x'), []); }); // #499: повторное применение зелёного вердикта без вызова модели — только по // записи конвейера и только при неизменённом дереве вне docs/reviews. const TREE_A = 'a'.repeat(40); const TREE_B = 'b'.repeat(40); const docWith = (name, anchors) => ({ name, text: withMaterialAnchors(`# ${name}\n\nВердикт: зелёный · заход r9\n`, anchors) }); test('блок якорей несёт вердикт конвейера, и он читается обратно (#499)', () => { const block = materialAnchorBlock({ sha: 'c'.repeat(40), tree: TREE_A, branch: 'issue/1-x', verdict: 'green', high: 0 }); assert.match(block, /Вердикт конвейера: `green` · High 0/); assert.deepEqual(anchorVerdictFrom(block), { verdict: 'green', high: 0 }); assert.equal(anchorTreeFrom(block), TREE_A); // Без вердикта строки нет — старые документы остаются старыми. assert.doesNotMatch(materialAnchorBlock({ tree: TREE_A }), /Вердикт конвейера/); assert.equal(anchorVerdictFrom(materialAnchorBlock({ tree: TREE_A })), null); // Проза вне блока якорей не читается как запись конвейера. assert.equal(anchorVerdictFrom('Вердикт конвейера: `green` · High 0\nбез маркера'), null); }); test('зелёный r3 с неизменённым деревом применяется повторно (#499)', () => { const docs = [ docWith('CODE-REVIEW-437-r1.md', { tree: TREE_B, verdict: 'yellow', high: 0 }), docWith('CODE-REVIEW-437-r3.md', { tree: TREE_A, verdict: 'green', high: 0 }), docWith('CODE-REVIEW-437-r2.md', { tree: TREE_B, verdict: 'yellow', high: 0 }), ]; const asked = []; const found = reusableGreenVerdict(docs, (tree) => { asked.push(tree); return false; }); assert.deepEqual(found, { doc: 'CODE-REVIEW-437-r3.md', round: 3, tree: TREE_A, verdict: 'green' }); assert.deepEqual(asked, [TREE_A], 'судится только последний раунд'); }); test('изменённое дерево, жёлтый вердикт, High>0 или проза без записи — полный разбор (#499)', () => { const green = docWith('CODE-REVIEW-1-r2.md', { tree: TREE_A, verdict: 'green', high: 0 }); assert.equal(reusableGreenVerdict([green], () => true), null, 'дерево отличается'); assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r2.md', { tree: TREE_A, verdict: 'yellow', high: 0 })], () => false), null); assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r2.md', { tree: TREE_A, verdict: 'green', high: 1 })], () => false), null); // Зелёный r2, но поверх него жёлтый r3 — последний решает. assert.equal(reusableGreenVerdict([green, docWith('CODE-REVIEW-1-r3.md', { tree: TREE_A, verdict: 'red', high: 2 })], () => false), null); // Документ без записи конвейера (до #499) — только проза «Вердикт: зелёный». assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r1.md', { tree: TREE_A })], () => false), null); // Якорь дерева отсутствует — сравнивать нечего. assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-1-r1.md', { verdict: 'green', high: 0 })], () => false), null); assert.equal(reusableGreenVerdict([], () => false), null); }); test('конвейер: посторонняя метка не входит в concurrency, guard читает текущие метки (#499)', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); // Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7. const head = workflow.slice(0, workflow.indexOf('\njobs:')); assert.doesNotMatch(head, /^concurrency:/m, 'concurrency на уровне workflow снова пустит в группу все метки'); const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:')); assert.match(guard, /if: github\.event\.label\.name == 'S4-spec-review' \|\| github\.event\.label\.name == 'S7-code-review'/); assert.match(guard, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/); for (const [job, next] of [['prepare', 'model_review'], ['model_review', 'integrate'], ['integrate', null]]) { const start = workflow.indexOf(`\n ${job}:`); const end = next ? workflow.indexOf(`\n ${next}:`) : workflow.length; assert.match(workflow.slice(start, end), /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/, `${job} сериализован по issue`); } // Состояние читается текущее, не из снимка события. assert.match(guard, /gh issue view "\$NUM" --repo "\$REPO" --json labels/); assert.doesNotMatch(guard, /contains\(github\.event\.issue\.labels/, 'снимок события больше не источник меток'); assert.match(guard, /запрос отозван/); }); test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/); const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:')); assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true'/, 'при повторном применении вся стадия модели пропускается'); const publish = workflow.slice(workflow.indexOf('- name: Опубликовать документ ревью'), workflow.indexOf('- name: Решение по вердикту')); assert.match(publish, /--verdict="\$verdict" --high="\$high"/); const decide = workflow.slice(workflow.indexOf('- name: Решение по вердикту'), workflow.indexOf('- name: dev ушёл вперёд')); assert.match(decide, /if \[ "\$REUSE" = "true" \]; then\n\s+(#[^\n]*\n\s+)*verdict=green; high=0/); // Ревьюер привязан к SHA материала — сам подтягивать новее не должен. assert.match(workflow, /Материал ревью — ровно\s+`\$\{\{ needs\.prepare\.outputs\.material_sha \}\}`/); }); test('#510 AC2: конвейер запускает Validate с мутантами на материале и не ревьюит красный', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); const gate = at(' - name: Validate с мутантами на материале\n'); assert.ok(material < reuse && reuse < gate, 'gate читает steps.reuse.outputs — стоит после шага reuse (ревью ТЗ r1)'); const back = at(' - name: Validate красный — вернуть автору без ревью\n'); const modelJob = at('\n model_review:\n'); const deps = at(' - name: Установить зависимости\n'); const review = at(' - name: Review\n'); assert.ok(material < gate && gate < back && back < modelJob && modelJob < deps && deps < review, 'гейт закончен в отдельной стадии до установки зависимостей/ревью'); const gateStep = workflow.slice(gate, back); assert.match(gateStep, /node scripts\/validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/); assert.match(gateStep, /if \[ "\$STAGE" != "code" \] \|\| \[ "\$REUSE" = "true" \]/, 'этап spec и reuse гейт не проходят'); assert.match(gateStep, /SHA: \$\{\{ steps\.material\.outputs\.sha \}\}/, 'проверяется именно материал'); // skip-ветка (spec/reuse) даёт proceed=true: ревью идёт, возврата S7→S6 нет (ревью ТЗ r2) assert.match(gateStep, /\{ echo 'proceed=true'; echo 'result=skipped'; \}/, 'skipped = proceed'); assert.doesNotMatch(workflow.slice(modelJob), /steps\.gate\.outputs/, 'следующие jobs не читают локальные outputs prepare'); const backStep = workflow.slice(back, deps); assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed != 'true'/); assert.match(backStep, /--add-label S6-in-progress --remove-label S7-code-review/); assert.match(backStep, /цикл ревью не израсходован/); assert.match(workflow.slice(modelJob, deps), /if: needs\.prepare\.outputs\.proceed == 'true'/, 'красный prepare вообще не запускает модель'); const integrate = workflow.slice(at('\n integrate:\n')); assert.match(integrate, /PROCEED: \$\{\{ needs\.prepare\.outputs\.proceed \}\}/); assert.match(integrate, /if \[ "\$PROCEED" != "true" \]; then[\s\S]*echo "proceed=false"/, 'интеграция не применяется после красного gate'); }); test('#515: якоря материала снимаются ПОСЛЕ ребейза конвейером и публикуются из шага material', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const rebase = at(' - name: Привести ветку к dev\n'); const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); assert.ok(rebase < material && material < reuse, 'material стоит после ребейза и до reuse'); const materialStep = workflow.slice(material, reuse); assert.match(materialStep, /echo "tree=\$\(git rev-parse 'HEAD\^\{tree\}'\)" >> "\$GITHUB_OUTPUT"/, 'дерево — из шага material'); assert.match(materialStep, /echo "specs=\$specs" >> "\$GITHUB_OUTPUT"/, 'ТЗ — из шага material'); const publish = at(' - name: Опубликовать документ ревью\n'); const publishStep = workflow.slice(publish, at(' - name: "Материал раунда воспроизводим (#413)"\n')); for (const name of ['SHA', 'TREE', 'SPECS']) { assert.match(publishStep, new RegExp(`MATERIAL_${name}: \\$\\{\\{ needs\\.prepare\\.outputs\\.material_${name.toLowerCase()} \\}\\}`), `MATERIAL_${name} из prepare`); } // до-ребейзные якоря из шага branch никем не читаются: после force-push они мертвы (#508 r1–r3) assert.doesNotMatch(workflow, /steps\.branch\.outputs\.(sha|tree|specs)/, 'якоря из шага branch — осиротевшие после ребейза'); }); // --- ТЗ живёт в теле issue: хеш тела как якорь материала (#517) -------------- const BODY_A = issueBodyDigest('## ТЗ\n\n- AC1. Как было'); const BODY_B = issueBodyDigest('## ТЗ\n\n- AC1. Как стало'); test('#517 AC1: нормализация тела гасит правки, которых в тексте нет', () => { const canonical = '## ТЗ\n\n- AC1. Строка\n- AC2. Вторая'; assert.equal(normalizeIssueBody('## ТЗ\r\n\r\n- AC1. Строка \r\n- AC2. Вторая\n\n\n'), canonical); assert.equal(issueBodyDigest('## ТЗ\r\n\r\n- AC1. Строка \n- AC2. Вторая'), issueBodyDigest(canonical)); // Содержательный пробел внутри строки значим: в ТЗ есть таблицы и код. assert.notEqual(issueBodyDigest('| AC1 | текст |'), issueBodyDigest('| AC1 | текст |')); assert.match(BODY_A, /^[0-9a-f]{64}$/); assert.notEqual(BODY_A, BODY_B); }); test('#517 AC1: блок якорей несёт хеш тела и читается обратно', () => { const block = materialAnchorBlock({ tree: TREE_A, issueBody: BODY_A, verdict: 'green', high: 0 }); assert.match(block, new RegExp(`- Тело issue: \`${BODY_A}\``)); assert.equal(anchorIssueBodyFrom(block), BODY_A); // Старые документы без строки читаются как раньше, а не как «хеш пустой». assert.equal(anchorIssueBodyFrom(materialAnchorBlock({ tree: TREE_A })), null); assert.doesNotMatch(materialAnchorBlock({ tree: TREE_A }), /Тело issue/); // Проза вне машинного блока записью не считается. assert.equal(anchorIssueBodyFrom(`Тело issue: \`${BODY_A}\`\nбез маркера`), null); // Ветки задачи нет, но тело есть — материал воспроизводим, отговорки нет. assert.doesNotMatch(materialAnchorBlock({ issueBody: BODY_A }), /Якоря снять не удалось/); assert.match(materialAnchorBlock({}), /Якоря снять не удалось/); }); test('#517 AC2: правка тела после зелёного ревью ТЗ — находка; неизменённое молчит', () => { const green = docWith('SPEC-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0, issueBody: BODY_A }); const yellow = docWith('SPEC-REVIEW-9-r1.md', { tree: TREE_B, verdict: 'yellow', high: 0, issueBody: BODY_B }); assert.equal(issueBodyChanged([yellow, green], BODY_A), null, 'текст тот же — молчим'); const changed = issueBodyChanged([yellow, green], BODY_B); assert.equal(changed.doc, 'SPEC-REVIEW-9-r2.md'); assert.equal(changed.recorded, BODY_A); assert.equal(changed.current, BODY_B); // Судится последний ЗЕЛЁНЫЙ, а не последний вообще. const red = docWith('SPEC-REVIEW-9-r3.md', { tree: TREE_A, verdict: 'red', high: 1, issueBody: BODY_B }); assert.equal(issueBodyChanged([green, red], BODY_A), null); // Задачи до перехода: зелёного документа нет либо в нём нет записи — не находка. assert.equal(issueBodyChanged([yellow], BODY_B), null); assert.equal(issueBodyChanged([docWith('SPEC-REVIEW-9-r1.md', { tree: TREE_A, verdict: 'green', high: 0 })], BODY_A), null); assert.equal(issueBodyChanged([green], ''), null, 'хеша нет — сравнивать не с чем'); }); test('#517 AC6: reuse не применяет зелёный вердикт, если тело issue изменилось', () => { const green = docWith('CODE-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0, issueBody: BODY_A }); assert.ok(reusableGreenVerdict([green], () => false, BODY_A), 'тело то же — вердикт применим'); assert.equal(reusableGreenVerdict([green], () => false, BODY_B), null, 'тело менялось — нужен разбор'); // Документ без записи (весь бэклог до перехода) судится по дереву, как раньше. const legacy = docWith('CODE-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0 }); assert.ok(reusableGreenVerdict([legacy], () => false, BODY_B)); assert.ok(reusableGreenVerdict([green], () => false), 'хеш не передан — прежнее поведение'); }); test('#517: конвейер снимает хеш тела на материале и передаёт его в якоря, reuse и ревьюеру', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); const specBody = at(' - name: "ТЗ менялось после зелёного ревью ТЗ (#517)"\n'); const review = at(' - name: Review\n'); assert.ok(material < reuse && reuse < specBody && specBody < review, 'хеш снят до reuse, находка — до модели'); const materialStep = workflow.slice(material, reuse); assert.match(materialStep, /gh issue view "\$NUM" --repo "\$REPO" --json body/, 'тело читается в прогоне, не из события'); assert.match(materialStep, /echo "issue_body=\$digest" >> "\$GITHUB_OUTPUT"/); assert.match(workflow.slice(reuse, specBody), /--issue-body="\$\{ISSUE_BODY\}"/, 'reuse учитывает тело (AC6)'); assert.match(workflow, /--issue-body="\$MATERIAL_ISSUE_BODY"/, 'якорь попадает в документ'); assert.match(workflow, /needs\.prepare\.outputs\.spec_body_changed == 'true' &&/, 'находка уходит в промпт ревьюера'); }); test('#517 AC4: документы процесса не требуют файла ТЗ, индекс docs/specs удалён', () => { const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); const process = read('PROCESS.md'); // Цепочка §7.1 называет тело issue, а не файл. assert.match(process, /↔ ТЗ\s+тело issue, раздел `## ТЗ`/); assert.doesNotMatch(process, /Артефакт:\*\* `docs\/specs\//, 'файл ТЗ больше не артефакт этапа S3'); // Гейт судит текст, а не наличие файла. assert.match(process, /у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1`/); const agents = read('AGENTS.md'); assert.match(agents, /spec lives in the \*\*issue body\*\*/); assert.match(agents, /`docs\/specs\/` is an archive/); // README архива: ни одной строки индекса «| [#NN](…) | […](….md) |». const readme = read('docs/specs/README.md'); assert.match(readme, /архив/i); assert.equal((readme.match(/^\| \[#\d+\]/gm) || []).length, 0, 'таблица-индекс удалена'); assert.doesNotMatch(readme, /Статус ТЗ/); }); test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); const job = (name, next) => { const start = workflow.indexOf(`\n ${name}:`); assert.ok(start > 0, `job ${name} найден`); const end = next ? workflow.indexOf(`\n ${next}:`, start + 1) : workflow.length; assert.ok(end > start, `граница job ${name} найдена`); return workflow.slice(start, end); }; const prepare = job('prepare', 'model_review'); const model = job('model_review', 'integrate'); const integrate = job('integrate'); assert.match(prepare, /timeout-minutes: 55/); assert.match(model, /timeout-minutes: 45/); assert.match(integrate, /timeout-minutes: 55/); assert.match(prepare, /node scripts\/validate-gate\.mjs/); assert.doesNotMatch(model, /validate-gate\.mjs/, 'ожидания Validate нет в бюджете модели'); assert.match(model, /needs: \[guard, prepare\]/); assert.match(integrate, /needs: \[guard, prepare, model_review\]/); const seal = model.slice( model.indexOf('- name: Запечатать результат модели'), model.indexOf('- name: Передать результат интеграции'), ); assert.match(seal, /printf '%s' "\$OUT" > "\$RUNNER_TEMP\/verdict\.json"/, 'structured verdict сохраняется как объект до проверки'); assert.match(seal, /and \(\.summary \| type == "string"\)' "\$RUNNER_TEMP\/verdict\.json" >\/dev\/null/, 'jq валидирует сохранённый объект, не записывает boolean предиката вместо него'); assert.doesNotMatch(seal, /jq -e '[\s\S]*> "\$RUNNER_TEMP\/verdict\.json"/, 'stdout предиката jq не становится payload интеграции'); const resultOutput = integrate.slice( integrate.indexOf('- name: Проверить полноту и происхождение результата'), integrate.indexOf('- name: Опубликовать документ ревью'), ); assert.match(resultOutput, /cat "\$dir\/verdict\.json" >> "\$GITHUB_OUTPUT"[\s\S]*printf '\\nEOF_RESULT\\n' >> "\$GITHUB_OUTPUT"/, 'закрывающий delimiter начинается с новой строки даже у JSON без финального LF'); assert.doesNotMatch(resultOutput, /cat "\$dir\/verdict\.json"\n\s+echo 'EOF_RESULT'/, 'cat без гарантированного LF не приклеивает delimiter к JSON'); assert.match(model, /ref: \$\{\{ needs\.prepare\.outputs\.material_sha \}\}/, 'модель получает exact material, а не подвижную ветку'); assert.match(prepare, /review-prepared-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); assert.equal((workflow.match(/sha256sum -c manifest\.sha256/g) || []).length, 2, 'контракт проверяют и модель, и интеграция'); assert.match(model, /test "\$\(git rev-parse HEAD\)" = "\$MATERIAL_SHA"/); assert.match(model, /review-result-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); assert.match(integrate, /неполный или неожиданный набор evidence/); assert.match(integrate, /sha256sum -c manifest\.sha256/); assert.match(integrate, /\.run_id == \$run_id[\s\S]*\.material_sha == \$sha[\s\S]*\.material_tree == \$tree/, 'подмена run/SHA/tree между jobs отвергается'); assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/); assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/); assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/, 'интеграция не доверяет failed/cancelled/skipped модели'); assert.match(integrate, /цикл ревью не израсходован/); assert.match(integrate, /Бюджеты стадий \(#551\)/, 'длительности публикуются раздельно'); }); // #539: `workflow_dispatch` принимает только ref, а не SHA. Конвейер сам // переписывает ветку ребейзом и тут же просит GitHub разрешить эту же ссылку: // 12.09 на #536 диспатч встал на ДОпушевый SHA, гейт не нашёл прогона на // материале и вернул задачу автору, которому чинить было нечего. Шаг ребейза // обязан дождаться, что ссылка доехала, и спрашивать об этом REST — через него // же идёт диспатч. test('конвейер: ребейз не заканчивается, пока ссылка не укажет на новую вершину (#539)', () => { const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); const rebase = workflow.slice( workflow.indexOf(' - name: Привести ветку к dev\n'), workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'), ); assert.ok(rebase.length > 0, 'шаг ребейза найден'); assert.match(rebase, /gh api "repos\/\$\{\{ github\.repository \}\}\/git\/ref\/heads\/\$BRANCH"/, 'вершина спрашивается у REST, а не у git ls-remote'); assert.match(rebase, /^\s+GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}$/m, 'токен для REST есть'); const wait = rebase.indexOf('git api') >= 0 ? -1 : rebase.indexOf('gh api'); const fetchLocal = rebase.indexOf('git fetch -q origin "+refs/heads/$BRANCH'); assert.ok(wait > 0 && wait < fetchLocal, 'ожидание стоит после push и до конца шага'); // Сама сверка, а не только её обвязка: без этой строки цикл выходит на первой // же итерации, и ожидание становится декорацией. assert.match(rebase, /if \[ "\$seen" = "\$after" \]; then settled=true; break; fi/, 'ответ REST сверяется с новой вершиной'); assert.match(rebase, /ссылка \$BRANCH за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание'); });