Files
houseplan-card/scripts/review-doc-guard.mjs
T
Claude 23d6681d3e ci: публикация ревью-дока не имеет права трогать ничего, кроме документа
28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного markdown:
откатил отревьюженную реализацию #359, вернул старые чанки, оставил в dist/
двойной набор. dev держал откаченное дерево три часа. Сообщение коммита было
невинным, и от рутины инцидент отличался только диффом.

Механизм воспроизведён локально, а не предположен. `git checkout -- .`
восстанавливает рабочее дерево ИЗ ИНДЕКСА, `git clean -fd` убирает
неотслеживаемое — ни то, ни другое индекс не трогает. Ревьюер работает с Bash и,
проверяя «умеет ли тест падать», вполне может сделать git add; всё оставшееся у
него в индексе прежняя уборка сохраняла, и следующий git commit забирал это
вместе с документом.

Отсюда три рубежа, каждый закрывает свой отрезок пути.

База: reset --hard на свежий origin/$target снимает и индекс, и дерево разом.
Терять нечего — документ приезжает из RUNNER_TEMP, а не из рабочей копии.
Индексируется ровно один путь, а не каталог.

Индекс: перед коммитом дифф проверяется allowlist'ом docs/reviews/.

Диапазон: перед КАЖДЫМ push проверяется origin/$target...HEAD — то есть то, что
пуш добавит в ветку. Проверок две, потому что push делается из двух мест, и
второй путь срабатывает ровно тогда, когда dev ушёл вперёд — в тех самых
условиях, при которых случился bb2919f.

Пустой дифф — тоже отказ: публиковать нечего означает, что документа нет, а
прежняя редакция шага выходила тут с нулём и оставляла вердикт без артефакта
(#171). Сравнение по префиксу каталога, а не подстрокой: docs/reviews-old и
docs/reviewsx разрешёнными не считаются. Форс-пуш отсутствует и закреплён тестом.

Четыре мутанта проверены руками, два добавлены в реестр. Пятый — «убрать одну из
двух проверок диапазона» — сначала выжил: тест требовал наличия, а не количества.
Тест усилен до подсчёта, мутант убит.

Issue: #365
User-Visible: no
2026-08-29 10:38:36 +03:00

80 lines
4.9 KiB
JavaScript
Raw 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
/**
* Публикация ревью-документа не имеет права трогать ничего, кроме него (#365).
*
* git diff --name-only "origin/dev...HEAD" | node scripts/review-doc-guard.mjs
* node scripts/review-doc-guard.mjs --allow 'docs/specs/' < paths.txt
*
* Что случилось. 28.08 шаг публикации запушил в `dev` коммит `bb2919f` с
* тридцатью файлами вместо одного markdown: откатил отревьюженную реализацию
* #359, вернул старые чанки и оставил в `dist/` двойной набор. `dev` держал
* откаченное дерево три часа, пока владелец не восстановил его руками
* (`fd762fa`). Сообщение коммита при этом было невинным — «docs: review document
* for #359», — и от рутины инцидент отличался только диффом.
*
* Почему это класс, а не случай. Пушащий шаг ничем не ограничен по путям, а его
* рабочая копия может разойтись с origin по десятку причин: гонка параллельных
* агентов за `dev` (в тот вечер их было три), ревью длиной в сорок минут,
* ветка задачи, которой нет. Любой такой рассинхрон превращает «положить один
* markdown» в «затереть dev целиком», и заметить это может только аудит дельты.
* Релиз собирается из `dev` — рецидив уехал бы пользователям.
*
* Поэтому проверка судит не намерение шага, а его результат: набор путей,
* который пуш добавит в целевую ветку. Пустой список — тоже отказ: публиковать
* нечего, значит что-то пошло не так раньше.
*/
import { readFileSync } from 'node:fs';
export const REVIEW_DOC_ALLOWLIST = ['docs/reviews/'];
/**
* Пути вне разрешённых каталогов.
*
* Сравнение по префиксу каталога, а не по расширению: `docs/reviews/x.md`
* разрешён, `docs/reviews-old/x.md` — нет, потому что префикс каталога
* заканчивается слэшем и подстрокой не притворяется.
*/
export function pathsOutsideAllowlist(paths, allowlist = REVIEW_DOC_ALLOWLIST) {
const prefixes = allowlist.map((item) => (item.endsWith('/') ? item : `${item}/`));
return [...new Set((paths || [])
.map((line) => String(line).trim())
.filter(Boolean))]
.filter((path) => !prefixes.some((prefix) => path.startsWith(prefix)))
.sort();
}
/** Вердикт по набору путей: `null` — можно публиковать. */
export function reviewDocPushRefusal(paths, allowlist = REVIEW_DOC_ALLOWLIST) {
const cleaned = [...new Set((paths || []).map((line) => String(line).trim()).filter(Boolean))];
if (!cleaned.length) {
return 'публиковать нечего: дифф пуст, а шаг вызван — значит документ не создан'
+ ' либо база уже содержит его';
}
const outside = pathsOutsideAllowlist(cleaned, allowlist);
if (!outside.length) return null;
return `публикация ревью-документа задевает ${outside.length} путь(ей) вне`
+ ` ${allowlist.join(', ')}:\n ${outside.join('\n ')}\n`
+ 'Пуш отменён. Так 28.08 коммит bb2919f откатил dev на три часа:'
+ ' рабочая копия шага разошлась с origin, и «положить один markdown»'
+ ' превратилось в «затереть dev целиком» (#365).';
}
const invokedDirectly = process.argv[1]
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (invokedDirectly) {
const argv = process.argv.slice(2);
const allowArg = argv.find((item) => item.startsWith('--allow='));
const allowlist = allowArg
? allowArg.slice('--allow='.length).split(',').map((item) => item.trim()).filter(Boolean)
: REVIEW_DOC_ALLOWLIST;
const paths = readFileSync(0, 'utf8').split('\n');
const refusal = reviewDocPushRefusal(paths, allowlist);
if (refusal) {
console.error(`::error::${refusal.split('\n')[0]}`);
console.error(refusal);
process.exit(1);
}
const count = paths.map((line) => line.trim()).filter(Boolean).length;
console.log(`дифф публикации чист: ${count} файл(ов), все в ${allowlist.join(', ')}`);
}