mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
881 lines
59 KiB
JavaScript
881 lines
59 KiB
JavaScript
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('#553: канон разделяет review и исполнение тестов и не возвращает новые ТЗ в архив', () => {
|
||
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
|
||
const process = read('PROCESS.md');
|
||
const agents = read('AGENTS.md');
|
||
const status = read('docs/STATUS.md');
|
||
|
||
assert.doesNotMatch(process, /ревью[\s\S]{0,120}заменяет тестирование/i);
|
||
assert.doesNotMatch(agents, /review[^\n]{0,80}stands in for testing/i);
|
||
assert.doesNotMatch(process, /получает\s+нормальный файл ТЗ/i);
|
||
assert.match(process, /полное ТЗ в теле issue по §7\.1/);
|
||
assert.match(process, /Приёмка проверяет результат для человека/);
|
||
assert.match(process, /async \(порядок, отмена/);
|
||
assert.match(process, /zoom\/DPR/);
|
||
assert.match(process, /host\/input/);
|
||
assert.match(process, /Ревьюер отвечает за полноту доказательств AC/);
|
||
assert.match(process, /проверено чтением, не\s+исполнением/);
|
||
assert.doesNotMatch(status, /py3\.13|Python 3\.13/i);
|
||
assert.match(status, /uses repository-pinned Python/);
|
||
assert.doesNotMatch(agents, /Node 22|Python 3\.14/,
|
||
'точные runtime pins читаются из исполняемых источников, не из памятки');
|
||
});
|
||
|
||
test('#555: bounded reconciler wakes only lost review requests and emits one machine summary', () => {
|
||
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
|
||
const workflow = read('.github/workflows/process-reconcile.yml');
|
||
const processWorkflow = read('.github/workflows/process.yml');
|
||
const process = read('PROCESS.md');
|
||
const agents = read('AGENTS.md');
|
||
|
||
assert.match(processWorkflow,
|
||
/run-name: "process #\$\{\{ github\.event\.issue\.number \}\} · \$\{\{ github\.event\.label\.name \}\}/,
|
||
'run identity includes issue and requested stage');
|
||
assert.match(workflow, /cron: '7,37 \* \* \* \*'/);
|
||
assert.match(workflow, /workflow_dispatch:/);
|
||
assert.match(workflow, /ref: dev/);
|
||
assert.match(workflow, /secrets\.HP_PROCESS_TOKEN/);
|
||
assert.match(workflow, /node scripts\/process-reconcile\.mjs[\s\S]*--apply="\$APPLY"/);
|
||
assert.match(workflow, /--max-actions=5/);
|
||
assert.match(workflow, /process-reconcile-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
|
||
assert.match(process, /houseplan-process-reconcile\/v1/);
|
||
assert.match(process, /второй вызов модели или S8 по догадке запрещены/);
|
||
assert.match(agents, /bounded queue reconciler \(#555\)/);
|
||
});
|
||
|
||
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 за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание');
|
||
});
|