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
+23
View File
@@ -9946,6 +9946,29 @@ const MUTANT_DEFINITIONS = [
replace: " if (false && release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] }; // mutant: release review is unknown",
}],
},
// #682: архив документов ревью — два правила, без которых перенос ломает ссылки раундов.
{
id: 'reviews-archive-moves-open-line-issue',
guard: 'node --test --test-name-pattern="#682 архив: задача из открытой линии" test/reviews-archive.test.mjs',
because: 'an issue with a trailer after the archived tag is still in flight: its next round cites '
+ 'the previous documents under docs/reviews/, and moving them breaks «Унаследовано из r<N−1>»',
patches: [{
file: 'scripts/reviews-archive.mjs',
find: " if (openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; }",
replace: " if (false && openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; } // mutant: open line ignored",
}],
},
{
id: 'reviews-archive-first-line-wins',
guard: 'node --test --test-name-pattern="#682 архив: задача уходит" test/reviews-archive.test.mjs',
because: 'an issue with trailers in two released lines keeps all rounds in the latest one; '
+ 'the first line winning splits one issue across two archive folders',
patches: [{
file: 'scripts/reviews-archive.mjs',
find: ' for (const issue of line.issues) lineOf.set(Number(issue), line.tag); // последняя линия выигрывает',
replace: ' for (const issue of line.issues) if (!lineOf.has(Number(issue))) lineOf.set(Number(issue), line.tag); // mutant: first line wins',
}],
},
{
id: 'reviews-index-release-counts-ignored',
guard: 'node --test --test-name-pattern="#635 счётчики|#635 индекс покрывает" test/reviews-index.test.mjs',
+3
View File
@@ -65,6 +65,9 @@ const CLASS_B = [
const CLASS_C = [
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
/^legacy\//,
];
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
+12 -1
View File
@@ -78,6 +78,17 @@ export function issueMetrics(issue, events = []) {
};
}
/**
* Имена документов ревью из `git ls-tree -r --name-only` по живому каталогу и
* архиву (#682): после стабильного релиза документы линии уезжают в
* `legacy/reviews/<тег>/`, и счёт раундов за окно не должен от этого меняться.
*/
export function reviewDocNames(listing = '') {
return String(listing).split('\n').map((path) => path.trim()).filter(Boolean)
.filter((path) => path.startsWith('docs/reviews/') || path.startsWith('legacy/reviews/'))
.map((path) => path.slice(path.lastIndexOf('/') + 1));
}
/** Раунды ревью по именам документов `docs/reviews/*-REVIEW-<NN>-r<K>.md`. */
export function reviewRounds(fileNames = []) {
const rounds = new Map();
@@ -268,7 +279,7 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null }) {
if (rows.length < 100) break;
}
const reviewFiles = git
? git(['ls-tree', '--name-only', 'HEAD:docs/reviews']).split('\n').filter(Boolean)
? reviewDocNames(git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']))
: [];
return { issues, timelines, runs, reviewFiles };
}
+4 -2
View File
@@ -528,7 +528,8 @@ export function anchorVerdictFrom(text) {
* - последний опубликованный документ этапа несёт записанный КОНВЕЙЕРОМ
* вердикт `green` с High 0 (из structured_output модели, не из прозы);
* - его якорь «дерево материала» снят конвейером (#414), а не написан рукой;
* - текущее дерево отличается от якоря НИЧЕМ, кроме docs/reviews/** —
* - текущее дерево отличается от якоря НИЧЕМ, кроме docs/reviews/** (и
* переноса выпущенных документов в legacy/reviews/**, #682) —
* сравнение делает git по содержимому, так что ребейз на ушедший dev,
* правка теста, фикстуры, скрипта или ТЗ в docs/specs дают отличие и
* полный разбор (§2.10). Смена базы без изменения дерева невозможна:
@@ -762,7 +763,8 @@ if (invokedDirectly) {
// Сначала дерево обязано существовать: неизвестный объект — не «совпало».
const exists = spawnSync('git', ['cat-file', '-e', `${tree}^{tree}`], { encoding: 'utf8' });
if (exists.status !== 0) return true;
const diff = spawnSync('git', ['diff', '--quiet', tree, head, '--', '.', ':!docs/reviews'], { encoding: 'utf8' });
// #682: перенос выпущенных документов в legacy/reviews/ — тоже не материал.
const diff = spawnSync('git', ['diff', '--quiet', tree, head, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { encoding: 'utf8' });
return diff.status !== 0;
};
const found = reusableGreenVerdict(docs, differs, value('issue-body'));
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env node
/**
* Архив документов ревью выпущенных линий (#682, PROCESS.md §2.10, #635).
*
* node scripts/reviews-archive.mjs --through=v1.77.0 # план, ничего не пишет
* node scripts/reviews-archive.mjs --through=v1.77.0 --apply # git mv + пересборка INDEX.md
*
* В `docs/reviews/` живут документы задач текущей линии: следующие раунды
* ссылаются на предыдущие («Унаследовано из r<N−1>», якоря материала #413,
* #416), поэтому документы не удаляются и история не переписывается — после
* стабильного релиза они переезжают в `legacy/reviews/<тег>/` одним коммитом
* класса C. Кому куда, решает не метка и не память, а тот же довод, что у
* `RELEASE-MEMBERSHIP.json` (#547) и ревью линии (#638): трейлеры
* `Issue: #NN` в диапазоне линии «прошлый стабильный тег..тег».
*
* Правила (все — в `archivePlan`, чистой функции):
* - линия задачи — ПОСЛЕДНЯЯ стабильная линия ≤ `--through`, где у неё есть
* трейлер: документы одной задачи не разъезжаются по двум каталогам;
* - задача с трейлером в открытой линии (`--through..HEAD`) остаётся целиком:
* её раунды ещё продолжаются, а ссылки на прошлые раунды ведут в
* `docs/reviews/`. Это и есть граница, которую нельзя пересечь;
* - закрытая без выпуска задача (#522) переезжает с линией, в которой лёг её
* документ: коммит документа несёт тот же трейлер;
* - `RELEASE-REVIEW-vX.Y.Z.md` уходит в каталог своего тега, поэтому перенос
* линии делается после публикации её ревью;
* - документ задачи без трейлера ни в одной линии (работа до правила
* трейлеров, документ подшит позже) переезжает с линией, в которой лёг
* сам документ — первым стабильным тегом, содержащим его добавление;
* - имя вне схемы и документ, не попавший ни в одну линию, остаются на месте
* и печатаются — решает человек.
*/
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { isMainModule } from './spawn-portable.mjs';
import { issueTrailers } from './release-membership.mjs';
import { INDEX_FILE, parseDocName } from './reviews-index.mjs';
import { STABLE_TAG_RE } from './release-review.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const LIVE_DIR = 'docs/reviews';
export const ARCHIVE_DIR = 'legacy/reviews';
const parts = (tag) => STABLE_TAG_RE.exec(tag).slice(1, 4).map(Number);
export function compareStable(a, b) {
const [x, y] = [parts(a), parts(b)];
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
return 0;
}
/** Стабильные теги ≤ `through` по возрастанию; беты и чужие имена отброшены. */
export function stableTagsThrough(tags, through) {
if (!STABLE_TAG_RE.test(String(through))) throw new Error(`not a stable release tag: ${through}`);
return [...new Set(tags)].filter((tag) => STABLE_TAG_RE.test(tag) && compareStable(tag, through) <= 0)
.sort(compareStable);
}
/**
* План переноса.
*
* @param names имена файлов в `docs/reviews/`
* @param lines [{ tag, issues: Iterable<number> }] — стабильные линии ≤ through
* @param open Iterable<number> — задачи с трейлером в открытой линии
* @param through последний архивируемый стабильный тег
* @param addedIn Map<имя, тег> — линия, в которой документ добавлен (для задач без трейлера)
* @returns {{ moves: {name, from, to, tag, issue}[], kept: {name, reason, issue?}[] }}
*/
export function archivePlan({ names, lines, open, through, addedIn = new Map() }) {
const openSet = new Set([...open].map(Number));
const lineOf = new Map();
const ordered = [...lines].sort((a, b) => compareStable(a.tag, b.tag));
for (const line of ordered) {
if (compareStable(line.tag, through) > 0) throw new Error(`line ${line.tag} is newer than ${through}`);
for (const issue of line.issues) lineOf.set(Number(issue), line.tag); // последняя линия выигрывает
}
const tags = new Set(ordered.map((line) => line.tag));
const moves = [];
const kept = [];
for (const name of [...names].sort()) {
if (name === INDEX_FILE) continue;
const doc = parseDocName(name);
if (!doc) { kept.push({ name, reason: 'вне схемы имён' }); continue; }
if (doc.stage === 'release') {
if (tags.has(doc.tag)) moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${doc.tag}/${name}`, tag: doc.tag, issue: null });
else kept.push({ name, reason: `ревью линии ${doc.tag} не входит в архивируемые линии` });
continue;
}
if (openSet.has(doc.issue)) { kept.push({ name, issue: doc.issue, reason: 'задача есть в открытой линии' }); continue; }
const tag = lineOf.get(doc.issue) ?? (tags.has(addedIn.get(name)) ? addedIn.get(name) : null);
if (!tag) { kept.push({ name, issue: doc.issue, reason: 'нет трейлера ни в одной линии' }); continue; }
moves.push({ name, from: `${LIVE_DIR}/${name}`, to: `${ARCHIVE_DIR}/${tag}/${name}`, tag, issue: doc.issue });
}
return { moves, kept };
}
export function renderPlan({ moves, kept, through }) {
const byTag = new Map();
for (const move of moves) byTag.set(move.tag, (byTag.get(move.tag) || 0) + 1);
const reasons = new Map();
for (const item of kept) reasons.set(item.reason, (reasons.get(item.reason) || 0) + 1);
const issues = new Set(moves.map((move) => move.issue).filter((issue) => issue != null));
const lines = [
`Архив документов ревью по ${through}: переносится ${moves.length} (задач ${issues.size}), остаётся ${kept.length}.`,
...[...byTag].sort(([a], [b]) => compareStable(a, b)).map(([tag, count]) => ` ${ARCHIVE_DIR}/${tag}/: ${count}`),
...[...reasons].map(([reason, count]) => ` остаётся — ${reason}: ${count}`),
];
const notable = kept.filter((item) => item.reason !== 'задача есть в открытой линии');
if (notable.length) lines.push('Остаются на месте, решает человек:', ...notable.map((item) => ` ${item.name} — ${item.reason}`));
return lines.join('\n');
}
function git(args, cwd = ROOT) {
const run = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
if (run.status !== 0) throw new Error(`git ${args.join(' ')}: ${run.stderr.trim()}`);
return run.stdout;
}
/** Задачи с трейлером в диапазоне `range` (`a..b` или один ref — вся история до него). */
export function trailerIssues(range, cwd = ROOT) {
const log = git(['log', '--format=%B%x1e', range], cwd);
return new Set(log.split('\x1e').flatMap((message) => issueTrailers(message)));
}
export function readInputs({ through, head = 'HEAD', cwd = ROOT }) {
const reachable = git(['tag', '--merged', through, '-l', 'v*'], cwd).split('\n').filter(Boolean);
const stable = stableTagsThrough([...reachable, through], through);
const lines = stable.map((tag, index) => ({
tag,
issues: trailerIssues(index === 0 ? tag : `${stable[index - 1]}..${tag}`, cwd),
}));
const open = trailerIssues(`${through}..${head}`, cwd);
const names = git(['ls-tree', '--name-only', `${head}:${LIVE_DIR}`], cwd).split('\n').filter(Boolean);
return { names, lines, open };
}
/**
* Линия добавления документа: первый стабильный тег ≤ through, содержащий
* коммит, который добавил файл. Спрашивается только для документов, у задачи
* которых нет трейлера ни в одной линии — их единицы.
*/
export function addedLines({ names, through, head = 'HEAD', cwd = ROOT }) {
const result = new Map();
for (const name of names) {
const sha = git(['log', '--diff-filter=A', '--format=%H', '-1', head, '--', `${LIVE_DIR}/${name}`], cwd).trim();
if (!sha) continue;
const containing = stableTagsThrough(git(['tag', '--contains', sha, '-l', 'v*'], cwd).split('\n').filter(Boolean), through);
if (containing.length) result.set(name, containing[0]);
}
return result;
}
export function applyPlan({ moves, cwd = ROOT }) {
const dirty = git(['status', '--porcelain', '--', LIVE_DIR, ARCHIVE_DIR], cwd).trim();
if (dirty) throw new Error(`рабочее дерево ${LIVE_DIR}/${ARCHIVE_DIR} не чистое:\n${dirty}`);
for (const move of moves) {
if (existsSync(join(cwd, move.to))) throw new Error(`${move.to} уже существует`);
mkdirSync(join(cwd, dirname(move.to)), { recursive: true });
git(['mv', move.from, move.to], cwd);
}
const index = spawnSync(process.execPath, [join(ROOT, 'scripts/reviews-index.mjs'), `--dir=${LIVE_DIR}`], { cwd, encoding: 'utf8' });
if (index.status !== 0) throw new Error(`reviews-index: ${index.stderr || index.stdout}`);
git(['add', '--', join(LIVE_DIR, INDEX_FILE)], cwd);
}
if (isMainModule(import.meta.url)) {
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const through = arg('through');
if (!through || !STABLE_TAG_RE.test(through)) {
console.error('usage: node scripts/reviews-archive.mjs --through=vX.Y.Z [--head=HEAD] [--apply]');
process.exit(2);
}
const head = arg('head') || 'HEAD';
const inputs = readInputs({ through, head });
const first = archivePlan({ ...inputs, through });
const stragglers = first.kept.filter((item) => item.reason === 'нет трейлера ни в одной линии').map((item) => item.name);
const plan = stragglers.length
? archivePlan({ ...inputs, through, addedIn: addedLines({ names: stragglers, through, head }) })
: first;
console.log(renderPlan({ ...plan, through }));
if (process.argv.includes('--apply')) {
applyPlan({ moves: plan.moves });
console.log(`перенесено ${plan.moves.length}; ${LIVE_DIR}/${INDEX_FILE} пересобран. Коммит — класс C, с трейлером задачи.`);
} else {
console.log('план (--apply выполнит git mv и пересоберёт индекс)');
}
}
+1 -1
View File
@@ -265,7 +265,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
const anchorTree = reviewDocs.length ? anchorTreeFrom(reviewDocs.at(-1).text) : null;
let treeWithoutReviews = null;
if (anchorTree) {
const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews'], { cwd });
const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { cwd });
treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`;
}
branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure };