Files
houseplan-card/test/review-doc-guard.test.mjs
T

881 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание');
});