feat(tools): архив документов ревью выпущенных линий (#682)

Волна 5 эпика #674, инструментальная часть (класс B).
`scripts/reviews-archive.mjs --through=vX.Y.Z` печатает план переноса
документов ревью в `legacy/reviews/<тег>/`, `--apply` делает `git mv` и
пересобирает `docs/reviews/INDEX.md`. Членство — трейлеры `Issue: #NN` в
диапазоне линии, как у манифеста беты (#547) и ревью линии (#638). Правила —
в чистой `archivePlan`: задача уходит в последнюю свою линию; задача с
трейлером после тега остаётся целиком (её раунды ссылаются на прошлые);
закрытая без выпуска уходит с линией, где лёг её документ; документ задачи
без трейлера — с линией, где его добавили; RELEASE-REVIEW — в каталог
своего тега; чужие имена не трогаются. План по v1.77.0: 965 документов
332 задач, 154 остаются в открытой линии.

`legacy/` — класс C в process-gate. Сравнения деревьев с якорем вердикта
(`review-doc-guard.mjs` #499, `task-packet.mjs`) не видят переноса в
`legacy/reviews/`. `process-metrics.mjs` считает раунды по живому каталогу и
архиву. Порог «>900 документов» в тесте индекса снят: в каталоге остаётся
текущая линия. PROCESS.md §2.10 уточнён (правила членства, пустая очередь
S7, ревью линии до переноса), в DEVELOPMENT › Release — шаг чеклиста.
Юнит-тесты и два мутанта (`reviews-archive-moves-open-line-issue`,
`reviews-archive-first-line-wins`).

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-27 22:10:46 +00:00
committed by claude[bot]
parent c64a1ab7d5
commit d4a672c715
12 changed files with 341 additions and 10 deletions
+2
View File
@@ -49,6 +49,8 @@ test('paths classify into A/B/C/D with the generated tree winning over source',
assert.equal(classify('docs/SCOPE.md'), 'C');
assert.equal(classify('PROCESS.md'), 'C');
assert.equal(classify('CODE-REVIEW-111-r1.md'), 'C');
assert.equal(classify('legacy/reviews/v1.77.0/CODE-REVIEW-111-r1.md'), 'C'); // #682
assert.equal(classify('legacy/README.md'), 'C');
assert.equal(classify('something-unheard-of.xyz'), '?');
});
+16 -1
View File
@@ -3,7 +3,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewRounds, runMetrics,
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics,
} from '../scripts/process-metrics.mjs';
const T = (h) => new Date(Date.UTC(2026, 8, 15, 0, Math.round(h * 60))).toISOString();
@@ -140,3 +140,18 @@ test('#637 workflow: еженедельный запуск читает толь
assert.match(wf, /GITHUB_STEP_SUMMARY/);
assert.ok(!/issues: write/.test(wf), 'метрики ничего не пишут в issue');
});
test('#682 reviewDocNames: живой каталог и архив legacy/reviews/<тег>/ считаются вместе', () => {
const listing = [
'docs/reviews/CODE-REVIEW-600-r2.md',
'docs/reviews/INDEX.md',
'legacy/reviews/v1.77.0/CODE-REVIEW-600-r1.md',
'legacy/reviews/v1.77.0/SPEC-REVIEW-601-r1.md',
'legacy/docs/ROADMAP.md',
'',
].join('\n');
const names = reviewDocNames(listing);
assert.deepEqual(names, ['CODE-REVIEW-600-r2.md', 'INDEX.md', 'CODE-REVIEW-600-r1.md', 'SPEC-REVIEW-601-r1.md']);
// Перенос r1 в архив не уменьшает число раундов задачи.
assert.equal(reviewRounds(names).get('CODE:600'), 2);
});
+68
View File
@@ -0,0 +1,68 @@
// #682: архив документов ревью выпущенных линий — кому куда, решают трейлеры.
import assert from 'node:assert/strict';
import test from 'node:test';
import { ARCHIVE_DIR, LIVE_DIR, archivePlan, renderPlan, stableTagsThrough } from '../scripts/reviews-archive.mjs';
const lines = [
{ tag: 'v1.76.0', issues: [500, 510, 520] },
{ tag: 'v1.77.0', issues: [520, 530, 540] },
];
const names = [
'INDEX.md',
'SPEC-REVIEW-500-r1.md', 'CODE-REVIEW-500-r1.md', 'CODE-REVIEW-500-r2.md',
'SPEC-REVIEW-520-r1.md', 'CODE-REVIEW-520-r1.md',
'CODE-REVIEW-540-r1.md', 'CODE-REVIEW-540-r2.md',
'CODE-REVIEW-600-r1.md',
'CODE-REVIEW-issue-068-2026-08-12.md',
'RELEASE-REVIEW-v1.77.0.md', 'RELEASE-REVIEW-v1.78.0.md',
'notes.md',
];
const where = (plan, name) => plan.moves.find((m) => m.name === name)?.to
?? plan.kept.find((k) => k.name === name)?.reason;
test('#682 архив: задача уходит в каталог последней своей линии, документы не разъезжаются', () => {
const plan = archivePlan({ names, lines, open: [600], through: 'v1.77.0' });
assert.equal(where(plan, 'CODE-REVIEW-500-r2.md'), `${ARCHIVE_DIR}/v1.76.0/CODE-REVIEW-500-r2.md`);
// #520 — трейлеры в обеих линиях: все раунды в последней.
assert.equal(where(plan, 'SPEC-REVIEW-520-r1.md'), `${ARCHIVE_DIR}/v1.77.0/SPEC-REVIEW-520-r1.md`);
assert.equal(where(plan, 'CODE-REVIEW-520-r1.md'), `${ARCHIVE_DIR}/v1.77.0/CODE-REVIEW-520-r1.md`);
assert.equal(where(plan, 'RELEASE-REVIEW-v1.77.0.md'), `${ARCHIVE_DIR}/v1.77.0/RELEASE-REVIEW-v1.77.0.md`);
assert.ok(plan.moves.every((m) => m.from === `${LIVE_DIR}/${m.name}`));
assert.ok(!plan.moves.some((m) => m.name === 'INDEX.md') && !plan.kept.some((k) => k.name === 'INDEX.md'));
});
test('#682 архив: задача из открытой линии остаётся целиком — её раунды ссылаются на прошлые', () => {
// #540 выпущена в v1.77.0, но у неё есть трейлер и после тега.
const plan = archivePlan({ names, lines, open: [540, 600], through: 'v1.77.0' });
assert.equal(where(plan, 'CODE-REVIEW-540-r1.md'), 'задача есть в открытой линии');
assert.equal(where(plan, 'CODE-REVIEW-540-r2.md'), 'задача есть в открытой линии');
assert.equal(where(plan, 'CODE-REVIEW-600-r1.md'), 'задача есть в открытой линии');
assert.equal(where(plan, 'RELEASE-REVIEW-v1.78.0.md'), 'ревью линии v1.78.0 не входит в архивируемые линии');
});
test('#682 архив: без трейлера — по линии добавления документа, иначе остаётся; чужое имя не трогается', () => {
const bare = archivePlan({ names, lines, open: [600], through: 'v1.77.0' });
assert.equal(where(bare, 'CODE-REVIEW-issue-068-2026-08-12.md'), 'нет трейлера ни в одной линии');
assert.equal(where(bare, 'notes.md'), 'вне схемы имён');
const resolved = archivePlan({
names, lines, open: [600], through: 'v1.77.0',
addedIn: new Map([['CODE-REVIEW-issue-068-2026-08-12.md', 'v1.76.0'], ['notes.md', 'v1.76.0']]),
});
assert.equal(where(resolved, 'CODE-REVIEW-issue-068-2026-08-12.md'), `${ARCHIVE_DIR}/v1.76.0/CODE-REVIEW-issue-068-2026-08-12.md`);
assert.equal(where(resolved, 'notes.md'), 'вне схемы имён');
// Тег добавления вне архивируемых линий (бета, будущая линия) — не повод переносить.
const future = archivePlan({ names, lines, open: [600], through: 'v1.77.0', addedIn: new Map([['CODE-REVIEW-issue-068-2026-08-12.md', 'v1.78.0']]) });
assert.equal(where(future, 'CODE-REVIEW-issue-068-2026-08-12.md'), 'нет трейлера ни в одной линии');
const report = renderPlan({ ...bare, through: 'v1.77.0' });
assert.match(report, /переносится 8 \(задач 3\), остаётся 4/);
assert.match(report, /CODE-REVIEW-issue-068-2026-08-12\.md — нет трейлера/);
});
test('#682 архив: линии — только стабильные теги не новее границы', () => {
assert.deepEqual(
stableTagsThrough(['v1.78.0-beta.1', 'v1.77.0', 'v1.76.0', 'v1.10.0', 'v1.9.0', 'v1.78.0', 'x'], 'v1.77.0'),
['v1.9.0', 'v1.10.0', 'v1.76.0', 'v1.77.0'],
);
assert.throws(() => stableTagsThrough([], 'v1.78.0-beta.1'), /not a stable release tag/);
assert.throws(() => archivePlan({ names: [], lines: [{ tag: 'v1.78.0', issues: [] }], open: [], through: 'v1.77.0' }), /newer than/);
});
+3 -1
View File
@@ -119,7 +119,9 @@ test('#670 CLI --strict принимает ревью линии и отклон
test('#635 живой каталог docs/reviews: индекс свеж и покрывает все документы', () => {
const { entries, skipped } = collectEntries(fileURLToPath(new URL('../docs/reviews/', import.meta.url)));
assert.equal(skipped.length, 0, `вне схемы имён: ${skipped.join(', ')}`);
assert.ok(entries.length > 900);
// #682: выпущенные линии уезжают в legacy/reviews/<тег>/ — здесь только
// текущая линия, и её размер не константа. Пустой каталог — тоже поломка.
assert.ok(entries.length > 0);
const recognised = entries.filter((e) => e.verdict !== '—').length;
assert.ok(recognised / entries.length > 0.9, `вердикт распознан у ${recognised} из ${entries.length}`);
});