mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
+13
-4
@@ -397,10 +397,19 @@ dev, ни при публикации документа код-ревью: ин
|
||||
**Хранение документов** (решение владельца 23.09, #635): в `docs/reviews/`
|
||||
лежат все раунды всех задач текущей линии — предыдущие раунды нужны ссылкам
|
||||
«Унаследовано из r<N−1>» и якорям материала. При стабильном релизе документы
|
||||
задач, вошедших в него (`RELEASE-MEMBERSHIP.json` линии), переносятся в
|
||||
`legacy/reviews/<vX.Y.Z>/` одним коммитом класса C; индекс пересобирается и
|
||||
перечисляет только живые. Перенос — часть чеклиста стабильного релиза, не
|
||||
отдельная задача.
|
||||
задач, вошедших в него, переносятся в `legacy/reviews/<vX.Y.Z>/` одним
|
||||
коммитом класса 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 и пересоберёт индекс)');
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'), '?');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user