Files
houseplan-card/test/review-doc-guard.test.mjs
T
Codex 156835c048 ci: specs live in the issue body — body digest in review anchors, gate without a spec file
The spec file solved exactly one problem — proving that a review verdict
was passed on a given text — and created two: docs/specs/README.md
conflicted between parallel tasks and served as a second, stale status
dictionary, and every spec edit cost a commit, a push and a label. The
proof moves into the pipeline.

- review-doc-guard: normalizeIssueBody / issueBodyDigest (CRLF, trailing
  whitespace, trailing newlines), the anchor line `Тело issue: <sha256>`,
  anchorIssueBodyFrom, and issueBodyChanged — the finding "the spec
  changed after a green spec review", judged against the pipeline's own
  record in the last green SPEC-REVIEW, never against prose.
- reusableGreenVerdict takes the current digest: reuse (#499) skips the
  model entirely, so without this a spec edit between rounds would pass
  unseen. Documents without the record (the whole backlog) keep judging
  by tree.
- process.yml: the material step reads the body with `gh issue view` in
  the same run that fixes the material — the event snapshot describes a
  text the reviewer may never see; the digest goes into the anchors, into
  reuse and, when it differs, into the reviewer's prompt.
- process-gate: rule 3 judges the text (a `## ТЗ` heading or an AC1) with
  the archived file still accepted; adding a new file under docs/specs/
  warns — the directory is frozen.
- task-packet reads AC from the body first, the archived file second.
- PROCESS.md §2.3/§5/§7.1/§7.3/§10.5, AGENTS.md and docs/specs/README.md
  say so; the index table is gone with the long-standing §7.3 debt.

Mutants: review-anchor-drops-issue-body, review-ignores-changed-spec-body,
reuse-ignores-changed-issue-body, process-gate-requires-spec-file.

Issue: #517
User-Visible: no
2026-09-10 10:18:17 +03:00

746 lines
50 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 review:'));
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 \}\}/);
const review = workflow.slice(workflow.indexOf('\n review:'));
assert.match(review, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/);
// Состояние читается текущее, не из снимка события.
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 reviewStep = workflow.slice(workflow.indexOf(' - name: Review\n'), workflow.indexOf('anthropics/claude-code-action'));
assert.match(reviewStep, /steps\.reuse\.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+`\$\{\{ steps\.material\.outputs\.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 deps = at(' - name: Установить зависимости\n');
const review = at(' - name: Review\n');
assert.ok(material < gate && gate < back && back < 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(back), /if:[^\n]*steps\.gate\.outputs\.result/, 'условия шагов — только по proceed, result идёт в текст комментария');
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, /цикл ревью не израсходован/);
// всё, что после гейта, условно по proceed — включая перестановку метки и слияние
const after = workflow.slice(deps);
assert.doesNotMatch(after, /if: steps\.rebase\.outputs\.conflict != 'true'/, 'после гейта нет шагов, условных только по конфликту');
for (const name of ['Установить зависимости', 'Review', 'Решение по вердикту', 'Слить ветку в dev', 'Переставить метку']) {
const i = at(` - name: ${name}\n`);
const chunk = workflow.slice(i, i + 400);
assert.match(chunk, /if: (needs\.guard\.outputs\.stage == 'code' && )?steps\.(gate\.outputs\.proceed == 'true'|decide\.outputs\.green == 'true')/, `${name}: условие по proceed/зелёному`);
}
});
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}: \\$\\{\\{ steps\\.material\\.outputs\\.${name.toLowerCase()} \\}\\}`), `MATERIAL_${name} из material`);
}
// до-ребейзные якоря из шага 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, /steps\.spec_body\.outputs\.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, /Статус ТЗ/);
});