Files
Claudeandclaude[bot] 0991c45374 fix(tools): архив переписывает относительные ссылки перенесённых документов (#682)
Ревью #682 r1, Medium: перенос добавляет документу уровень вложенности
(`docs/reviews/X.md` → `legacy/reviews/<тег>/X.md`, `docs/specs/` →
`legacy/specs/`), а относительные ссылки внутри перенесённых документов и в
соседях, ссылавшихся на них, никто не пересчитывал — на `97d19268` 53 битые
ссылки в 46 файлах (заявление «все 26 резолвятся» в `7feb6177` было верно
только до переноса документов ревью). Гейты архив не смотрят.

`reviews-archive.mjs`: `repairLinks` пересчитывает ссылку, если она не
резолвится от нового места, а цель находится от нового или старого места
через карту переносов; битая и до переноса ссылка не трогается. `--apply`
делает это само, `--repair-links=<rev>` — для всех переименований
`<rev>..HEAD`, `--check-links` печатает битые. Этим коммитом
`--repair-links=origin/dev` переписал ровно 53 ссылки в 46 файлах; остались
две прежние «...»-заглушки в CODE-REVIEW-448-r2 (битые и на dev). Тесты:
перенесённый документ, сосед со ссылкой в архив, ТЗ со ссылкой на позже
перенесённое ревью, битая-до-переноса не трогается, в `legacy/` битых нет;
мутант `reviews-archive-links-from-new-place-only`. PROCESS §2.10 и
DEVELOPMENT › Release называют переписывание и `--check-links`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:47 +00:00

303 lines
17 KiB
JavaScript
Raw Permalink 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.
#!/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` уходит в каталог своего тега, поэтому перенос
* линии делается после публикации её ревью;
* - документ задачи без трейлера ни в одной линии (работа до правила
* трейлеров, документ подшит позже) переезжает с линией, в которой лёг
* сам документ — первым стабильным тегом, содержащим его добавление;
* - имя вне схемы и документ, не попавший ни в одну линию, остаются на месте
* и печатаются — решает человек.
*
* Перенос добавляет документу уровень вложенности (`docs/reviews/X.md` →
* `legacy/reviews/<тег>/X.md`), поэтому `--apply` переписывает относительные
* Markdown-ссылки — и внутри перенесённых файлов, и в соседях, которые на них
* ссылаются (`repairLinks`, ревью #682 r1). `--repair-links=<rev>` делает то же
* для всех переименований `<rev>..HEAD`, `--check-links` печатает битые
* относительные ссылки архива и живых каталогов.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, posix, 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;
}
const LINK_RE = /(\]\()([^)\s]+)(\))/g;
const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i;
/**
* Переписать относительные ссылки одного Markdown-файла после переносов.
*
* `path` — где файл лежит сейчас, `oldPath` — где лежал до переноса (для
* неперенесённого совпадает). Ссылка, которая и так резолвится от `path`,
* не трогается. Иначе цель ищется от `path` и от `oldPath`, проводится через
* `moved` (старый путь → новый), и если так найден существующий файл — ссылка
* пересчитывается от нового места. Ссылка, битая и до переноса, остаётся как
* была: чинить чужую историю — не дело архива.
*
* @returns {{ text: string, fixed: number }}
*/
export function repairLinks({ text, path, oldPath = path, moved, exists }) {
let fixed = 0;
const out = String(text).replace(LINK_RE, (whole, open, target, close) => {
if (EXTERNAL.test(target)) return whole;
const hash = target.indexOf('#');
const file = hash >= 0 ? target.slice(0, hash) : target;
const anchor = hash >= 0 ? target.slice(hash) : '';
if (!file) return whole;
let decoded;
try { decoded = decodeURI(file); } catch { decoded = file; }
const here = posix.normalize(posix.join(posix.dirname(path), decoded));
if (exists(here)) return whole;
for (const base of [path, oldPath]) {
const candidate = posix.normalize(posix.join(posix.dirname(base), decoded));
const now = moved.get(candidate) ?? candidate;
if (now !== here && exists(now)) {
fixed += 1;
let rel = posix.relative(posix.dirname(path), now);
if (!rel.startsWith('.')) rel = rel || posix.basename(now);
return `${open}${rel}${anchor}${close}`;
}
}
return whole;
});
return { text: out, fixed };
}
/** Все отслеживаемые Markdown-файлы: ссылаться на перенесённый документ может любой. */
function trackedMarkdown(cwd) {
return git(['ls-files', '-z', '--', '*.md'], cwd).split('\0').filter(Boolean);
}
/**
* Прогнать `repairLinks` по всем Markdown-файлам дерева.
* @param moved Map<старый путь, новый путь>
*/
export function repairTreeLinks({ moved, cwd = ROOT }) {
const inverse = new Map([...moved].map(([from, to]) => [to, from]));
const exists = (rel) => existsSync(join(cwd, rel));
let files = 0; let links = 0;
for (const path of trackedMarkdown(cwd)) {
const full = join(cwd, path);
if (!existsSync(full)) continue;
const text = readFileSync(full, 'utf8');
const result = repairLinks({ text, path, oldPath: inverse.get(path) ?? path, moved, exists });
if (result.fixed) {
writeFileSync(full, result.text);
files += 1; links += result.fixed;
}
}
return { files, links };
}
/** Переименования `rev..HEAD` (`git diff -M`): карта старый путь → новый. */
export function renamesSince(rev, cwd = ROOT) {
const out = git(['diff', '-M', '--name-status', '--diff-filter=R', '-z', rev, 'HEAD'], cwd).split('\0').filter(Boolean);
const moved = new Map();
for (let i = 0; i < out.length; i += 3) moved.set(out[i + 1], out[i + 2]);
return moved;
}
/** Битые относительные ссылки в архиве и живых каталогах документов ревью и ТЗ. */
export function brokenLinks({ cwd = ROOT, roots = [LIVE_DIR, ARCHIVE_DIR, 'docs/specs', 'legacy/specs'] } = {}) {
const broken = [];
for (const path of trackedMarkdown(cwd).filter((p) => roots.some((root) => p.startsWith(`${root}/`)))) {
const text = readFileSync(join(cwd, path), 'utf8');
for (const [, , target] of text.matchAll(LINK_RE)) {
if (EXTERNAL.test(target)) continue;
const file = target.split('#')[0];
if (!file) continue;
let decoded;
try { decoded = decodeURI(file); } catch { decoded = file; }
const resolved = posix.normalize(posix.join(posix.dirname(path), decoded));
if (!existsSync(join(cwd, resolved))) broken.push({ path, target });
}
}
return broken;
}
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 repaired = repairTreeLinks({ moved: new Map(moves.map((move) => [move.from, move.to])), cwd });
if (repaired.files) git(['add', '-u', '--', '.'], 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);
return repaired;
}
if (isMainModule(import.meta.url)) {
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
if (arg('repair-links')) {
const result = repairTreeLinks({ moved: renamesSince(arg('repair-links')) });
console.log(`ссылок переписано ${result.links} в ${result.files} файл(ах)`);
process.exit(0);
}
if (process.argv.includes('--check-links')) {
const broken = brokenLinks();
for (const item of broken) console.log(`${item.path}: ${item.target}`);
console.log(`битых относительных ссылок: ${broken.length}`);
process.exit(broken.length ? 1 : 0);
}
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] | --repair-links=<rev> | --check-links');
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')) {
const repaired = applyPlan({ moves: plan.moves });
console.log(`перенесено ${plan.moves.length}; ссылок переписано ${repaired.links} в ${repaired.files} файл(ах); ${LIVE_DIR}/${INDEX_FILE} пересобран. Коммит — класс C, с трейлером задачи.`);
} else {
console.log('план (--apply выполнит git mv и пересоберёт индекс)');
}
}