diff --git a/PROCESS.md b/PROCESS.md index 189e75e1..21530b69 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -397,10 +397,19 @@ dev, ни при публикации документа код-ревью: ин **Хранение документов** (решение владельца 23.09, #635): в `docs/reviews/` лежат все раунды всех задач текущей линии — предыдущие раунды нужны ссылкам «Унаследовано из r» и якорям материала. При стабильном релизе документы -задач, вошедших в него (`RELEASE-MEMBERSHIP.json` линии), переносятся в -`legacy/reviews//` одним коммитом класса C; индекс пересобирается и -перечисляет только живые. Перенос — часть чеклиста стабильного релиза, не -отдельная задача. +задач, вошедших в него, переносятся в `legacy/reviews//` одним +коммитом класса C; индекс пересобирается и перечисляет только живые. Перенос — +часть чеклиста стабильного релиза, не отдельная задача, и делает его +`node scripts/reviews-archive.mjs --through=vX.Y.Z` (без `--apply` — только +план, #682). Членство — трейлеры `Issue: #NN` в диапазоне линии, как у +манифеста беты и ревью линии (§11.5); метка не доказательство. Уточнения: +задача с трейлером и после тега остаётся в `docs/reviews/` целиком — её раунды +ещё продолжаются; задача с трейлерами в двух выпущенных линиях уезжает целиком +в последнюю; закрытая без выпуска (как #522) уезжает с линией, где лёг её +документ — коммит документа несёт трейлер; `RELEASE-REVIEW-vX.Y.Z.md` уходит в +каталог своего тега, поэтому перенос делается после публикации ревью линии. +Перенос идёт при пустой очереди `S7-code-review`: ребейз чужой ветки иначе +упрётся в перемещённый каталог. Дешёвые гейты (`typecheck`, `test`, `build` с проверкой целостности сборки, `bundle-policy --verify`) гоняются в каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e1c2009e..4e156945 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -644,6 +644,15 @@ uploads the failed frames as `continuity-screencast`. The tree hash printed in the run summary is the identity between what E2E installed and what HACS downloads. +**After a stable release**, once its line review +(`RELEASE-REVIEW-vX.Y.Z.md`) is in `dev` and the `S7-code-review` queue is +empty, archive the line's review documents (`PROCESS.md` §2.10): +`node scripts/reviews-archive.mjs --through=vX.Y.Z` prints the plan, +`--apply` moves the files into `legacy/reviews/vX.Y.Z/` with `git mv` and +rebuilds `docs/reviews/INDEX.md`; commit the result as one class C commit +whose `Issue:` trailer names the issue doing the move or the repository-hygiene +umbrella (`PROCESS.md` §11.3). + Publishing a stable release by hand in the GitHub form still works, but fail-closed: `release: published` starts the same workflow, which immediately turns the release back into a draft and walks the same path; nothing installable diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index db972382..fcd5860c 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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»', + 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', diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 70ec6097..157d7d40 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -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']; diff --git a/scripts/process-metrics.mjs b/scripts/process-metrics.mjs index fd5e58c5..fbe79f8e 100644 --- a/scripts/process-metrics.mjs +++ b/scripts/process-metrics.mjs @@ -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--r.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 }; } diff --git a/scripts/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index 50025f2d..f8bade00 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -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')); diff --git a/scripts/reviews-archive.mjs b/scripts/reviews-archive.mjs new file mode 100644 index 00000000..9a754067 --- /dev/null +++ b/scripts/reviews-archive.mjs @@ -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», якоря материала #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 }] — стабильные линии ≤ through + * @param open Iterable — задачи с трейлером в открытой линии + * @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 и пересоберёт индекс)'); + } +} diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index c7bfebd2..8b4f2a6e 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -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 }; diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index a592dd6b..f0c8b275 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -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'), '?'); }); diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs index b2139d06..10083494 100644 --- a/test/process-metrics.test.mjs +++ b/test/process-metrics.test.mjs @@ -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); +}); diff --git a/test/reviews-archive.test.mjs b/test/reviews-archive.test.mjs new file mode 100644 index 00000000..dbf8c83f --- /dev/null +++ b/test/reviews-archive.test.mjs @@ -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/); +}); diff --git a/test/reviews-index.test.mjs b/test/reviews-index.test.mjs index 4ceafe87..1b4e3f43 100644 --- a/test/reviews-index.test.mjs +++ b/test/reviews-index.test.mjs @@ -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}`); });