mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 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
858 lines
52 KiB
JavaScript
858 lines
52 KiB
JavaScript
#!/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 { createHash } from 'node:crypto';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { readFileSync, writeFileSync } from 'node:fs';
|
||
import { isMainModule } from './spawn-portable.mjs';
|
||
|
||
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).';
|
||
}
|
||
|
||
/**
|
||
* Сколько первых строк документа считаются шапкой. Материал раунда объявляется
|
||
* там — измерено по корпусу: из 555 опубликованных ревью 409 называют SHA в
|
||
* первых пятнадцати строках. Дальше начинается проза, и в ней SHA упоминаются
|
||
* исторически («коммит bb2919f откатил dev»), проверять их нечего.
|
||
*/
|
||
export const REVIEW_HEADER_LINES = 20;
|
||
|
||
/** Строки шапки, объявляющие материал раунда. */
|
||
const MATERIAL_MARKER = /(Материал|Коммит дельты|SHA|HEAD\s*=|коммит)/;
|
||
|
||
/**
|
||
* Кандидаты в SHA. Границы подобраны по корпусу, а не по вкусу:
|
||
*
|
||
* - 7–40 знаков: короче не бывает сокращений git, длиннее не бывает sha1.
|
||
* Отсекает заодно sha256 (64) — их в отчётах много, и они не коммиты;
|
||
* - хотя бы одна буква a–f: иначе в кандидаты попадают номера прогонов и даты
|
||
* вида `20260901`;
|
||
* - не после `#`: цвет `#607d8bff` — восемь шестнадцатеричных знаков;
|
||
* - не внутри более длинной шестнадцатеричной последовательности и не через
|
||
* дефис: `sha256-…` и обрезанные хвосты хешей кандидатами не считаются.
|
||
*/
|
||
const SHA_CANDIDATE = /(?<![0-9a-f#-])[0-9a-f]{7,40}(?![0-9a-f-])/g;
|
||
|
||
/**
|
||
* SHA, объявленные материалом раунда: `[{ line, sha }]`.
|
||
*
|
||
* Зачем отдельная функция и почему только шапка. PROCESS.md §2.10 требует
|
||
* называть SHA предыдущего раунда затем, чтобы дельта следующего объявлялась
|
||
* воспроизводимой командой `git diff <sha>..HEAD`. Проверять имеет смысл ровно
|
||
* то, что этой командой пользуются: объявление материала. Исторические
|
||
* упоминания в прозе — не обещание воспроизводимости.
|
||
*/
|
||
export function citedMaterialShas(text, headerLines = REVIEW_HEADER_LINES) {
|
||
const found = [];
|
||
String(text ?? '').split('\n').slice(0, headerLines).forEach((line, index) => {
|
||
if (!MATERIAL_MARKER.test(line)) return;
|
||
for (const sha of line.match(SHA_CANDIDATE) || []) {
|
||
if (!/[a-f]/.test(sha)) continue;
|
||
found.push({ line: index + 1, sha });
|
||
}
|
||
});
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* Якоря из машинного блока: то, чем раунд воспроизводится после ребейза.
|
||
*
|
||
* Разбор нарочно грубый — ищутся сорокасимвольные хеши в блоке, а не структура.
|
||
* Блок машинный, его форма меняется вместе с этим файлом, и жёсткий парсер
|
||
* ломался бы на каждой правке формулировки.
|
||
*/
|
||
export function materialAnchorsFrom(text) {
|
||
const body = String(text ?? '');
|
||
const at = body.indexOf(ANCHOR_MARKER);
|
||
if (at < 0) return [];
|
||
return [...new Set(body.slice(at).match(/\b[0-9a-f]{40}\b/g) || [])];
|
||
}
|
||
|
||
/**
|
||
* Вердикт: `null` — все объявленные SHA существуют коммитами.
|
||
*
|
||
* Зачем этот рубеж (#413). `SPEC-REVIEW-403-r2.md` объявил материал раунда на
|
||
* `HEAD = 83005c3c`, и тот же SHA независимо назвал автор ТЗ в комментарии
|
||
* issue. Коммита с таким именем в репозитории нет и не было: клон не мелкий,
|
||
* `git rev-list --all` его не знает. Скорее всего значение снято до `amend`
|
||
* или `rebase` при публикации — то есть проверка `git rev-parse HEAD` перед
|
||
* выводом отчёта, которую требует §2.7, не выполнялась ни у автора, ни у
|
||
* ревьюера.
|
||
*
|
||
* Цена уже заплачена на следующем раунде: пункт «найти SHA, на котором получен
|
||
* предыдущий вердикт» выполнить буквально не удалось, реальный коммит
|
||
* реконструировали по содержимому диффа.
|
||
*
|
||
* Чего этот рубеж НЕ умеет, и это важно знать. Он судит момент публикации.
|
||
* Ветка задачи после ревью нередко перебазируется или сквошится, и SHA умирает
|
||
* уже потом — по корпусу таких объявлений 98 из 804. Здесь ловится другой
|
||
* класс: SHA, мёртвый уже в момент, когда его объявляют воспроизводимым.
|
||
*
|
||
* Достижимость проверяется от ссылок ПУБЛИКАЦИИ (`refs/remotes/origin/*` и
|
||
* теги), а не от локальных. Разница не теоретическая: осиротевший `83005c3c`
|
||
* до сих пор лежит объектом в клоне Codex и достижим там из локальной
|
||
* `refs/heads/issue/403-area-relocation-safety`, не обновлённой после ребейза.
|
||
* Читателю отчёта от этого нет никакой пользы — он может достать только то,
|
||
* что есть на origin. Локальная проверка дала бы «всё в порядке» ровно на той
|
||
* машине, где ошибку и совершили.
|
||
*
|
||
* @param resolveReachable функция `(shas) => Map<sha, ref|null>`
|
||
*/
|
||
export function danglingMaterialRefusal(
|
||
text, resolveReachable, headerLines = REVIEW_HEADER_LINES, resolveObjects = null,
|
||
) {
|
||
const cited = citedMaterialShas(text, headerLines);
|
||
if (!cited.length) return null;
|
||
const refs = resolveReachable([...new Set(cited.map((item) => item.sha))]);
|
||
const bad = cited.filter((item) => !refs.get(item.sha));
|
||
if (!bad.length) return null;
|
||
// Осиротевший SHA — ещё не потеря раунда, если якоря на месте (#414). Дерево
|
||
// и блобы адресуются содержимым: ребейз их не меняет, и материал находится
|
||
// командами из машинного блока. Отказ остаётся там, где не работает НИ ОДИН
|
||
// из объявленных способов найти материал.
|
||
const anchors = materialAnchorsFrom(text);
|
||
if (anchors.length && resolveObjects) {
|
||
const alive = anchors.filter((object) => resolveObjects(object));
|
||
if (alive.length) {
|
||
return { warning: 'SHA раунда осиротел, но материал воспроизводим по якорям:'
|
||
+ ` ${bad.map((item) => item.sha).join(', ')} недостижимы,`
|
||
+ ` якорей живых ${alive.length} из ${anchors.length}.`
|
||
+ ' Ребейз ветки после ревью — обычное дело; именно для этого якоря и'
|
||
+ ' дописываются (#414).' };
|
||
}
|
||
}
|
||
const lines = bad
|
||
.map((item) => ` строка ${item.line}: ${item.sha} → не достижим ни из одной ссылки origin`)
|
||
.join('\n');
|
||
return 'ревью-документ объявляет материал раунда на SHA, которого нет на'
|
||
+ ` origin:\n${lines}\n`
|
||
+ 'Команда `git diff <sha>..HEAD` из PROCESS.md §2.10 на таком отчёте не'
|
||
+ ' работает, а следующий раунд восстанавливает коммит по содержимому'
|
||
+ ' диффа руками (#413). Сверьте SHA командой `git rev-parse HEAD`'
|
||
+ ' непосредственно перед выводом отчёта — §2.7 требует именно этого,'
|
||
+ ' а не значения, записанного до amend или rebase.';
|
||
}
|
||
|
||
/** Маркер машинного блока: по нему блок находится и заменяется целиком. */
|
||
export const ANCHOR_MARKER = '<!-- material-anchors: сгенерировано конвейером (#414) -->';
|
||
|
||
/**
|
||
* Блок якорей материала — то, что переживает ребейз (#414).
|
||
*
|
||
* Зачем он, если SHA уже назван. SHA ветки — не свойство материала, а свойство
|
||
* истории, и история переписывается. На #403 спец-коммит переехал из
|
||
* `83005c3c` в `94502d3d` за пятнадцать минут до публикации отчёта: сообщение
|
||
* то же, содержимое то же, блоб ТЗ тот же (`56a92e12`), а команда из §2.10
|
||
* `git diff 83005c3c..HEAD` через раунд не работала. Следующий ревьюер
|
||
* восстанавливал коммит по содержимому диффа руками.
|
||
*
|
||
* Дерево и блоб адресуются содержимым, поэтому ребейз их не меняет: пока текст
|
||
* где-нибудь достижим, найти его можно одной командой. Именно эти команды и
|
||
* пишутся в блок — отчёт обязан быть исполняемым, а не описательным.
|
||
*
|
||
* Блок машинный и помечен как машинный. Ревьюер его не заполняет: дисциплина
|
||
* ручного переписывания SHA здесь уже подвела, и заменять её другой ручной
|
||
* дисциплиной смысла нет.
|
||
*/
|
||
/**
|
||
* Живость якоря материала — это ДОСТИЖИМОСТЬ, а не наличие объекта (#422).
|
||
*
|
||
* Первая версия спрашивала `git cat-file -e`, то есть «лежит ли объект в
|
||
* локальной базе». Объект, созданный на машине автора и никуда не привязанный,
|
||
* этой проверке удовлетворяет — и смягчает жёсткий отказ #413 именно там, где
|
||
* ошибку и совершили. Проверено исполнением: `git hash-object -w` даёт объект,
|
||
* который `cat-file -e` признаёт, а `git log --all --find-object` не находит.
|
||
*
|
||
* Здесь спрашивается то же, что документ печатает читателю в машинном блоке
|
||
* (`materialAnchorBlock`), но в области `refs/remotes/origin` и тегов: у
|
||
* ревьюера локальных веток автора нет, и якорь, живой только благодаря им, —
|
||
* это обещание, которое не выполнится.
|
||
*
|
||
* `run` вынесен параметром ради тестов: подставная проба позволяет проверить и
|
||
* выбор команды по типу объекта, и поведение на неизвестном типе, не заводя
|
||
* настоящий репозиторий.
|
||
*/
|
||
export function anchorLiveness(object, run) {
|
||
const type = run(['cat-file', '-t', object]);
|
||
if (type.status !== 0) return false;
|
||
const kind = String(type.stdout || '').trim();
|
||
if (kind === 'tree') {
|
||
// Дерево адресуется содержимым и не находится через --find-object:
|
||
// его ищут перебором %T по достижимым коммитам.
|
||
const probe = run(['log', '--remotes=origin', '--tags', '--format=%T']);
|
||
if (probe.status !== 0) return false;
|
||
return String(probe.stdout || '').split('\n').some((line) => line.trim() === object);
|
||
}
|
||
if (kind === 'blob') {
|
||
const probe = run([
|
||
'log', '--remotes=origin', '--tags', `--find-object=${object}`, '--max-count=1', '--format=%H',
|
||
]);
|
||
return probe.status === 0 && String(probe.stdout || '').trim() !== '';
|
||
}
|
||
if (kind === 'commit') {
|
||
const probe = run([
|
||
'for-each-ref', '--contains', object, '--count=1', '--format=%(refname)',
|
||
'refs/remotes/origin', 'refs/tags',
|
||
]);
|
||
return probe.status === 0 && String(probe.stdout || '').trim() !== '';
|
||
}
|
||
// Тег-объект и всё неизвестное живым не считается: якорь должен быть тем, что
|
||
// печатает конвейер, а не чем угодно похожим на хеш.
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Нормализация тела issue перед хешем (#517).
|
||
*
|
||
* GitHub отдаёт тело с `\r\n`, а веб-редактор дописывает пробелы в концах
|
||
* строк — без нормализации хеш менялся бы от правок, которых в тексте нет.
|
||
* Нормализуется ровно это: перевод строки, хвостовые пробелы каждой строки и
|
||
* финальные пустые строки. Содержательные пробелы внутри строк не трогаются:
|
||
* в ТЗ есть таблицы и блоки кода, где они значимы.
|
||
*/
|
||
export function normalizeIssueBody(text) {
|
||
return String(text ?? '')
|
||
.replace(/\r\n?/g, '\n')
|
||
.split('\n')
|
||
.map((line) => line.replace(/[ \t]+$/, ''))
|
||
.join('\n')
|
||
.replace(/\n+$/, '');
|
||
}
|
||
|
||
/** sha256 нормализованного тела issue — материал ревью ТЗ (#517). */
|
||
export function issueBodyDigest(text) {
|
||
return createHash('sha256').update(normalizeIssueBody(text), 'utf8').digest('hex');
|
||
}
|
||
|
||
/** Хеш тела из машинного блока документа ревью, либо `null` (#517). */
|
||
export function anchorIssueBodyFrom(text) {
|
||
const body = String(text ?? '');
|
||
const at = body.indexOf(ANCHOR_MARKER);
|
||
if (at < 0) return null;
|
||
const match = body.slice(at).match(/Тело issue: `([0-9a-f]{64})`/);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
/**
|
||
* Менялось ли ТЗ после зелёного ревью ТЗ (#517 AC2).
|
||
*
|
||
* `docs` — документы этапа spec, `digest` — хеш тела на момент этого захода.
|
||
* Сравнивается запись конвейера в последнем ЗЕЛЁНОМ документе: проза цитирует
|
||
* прошлые раунды и источником быть не может. Нет зелёного документа или в нём
|
||
* нет строки `Тело issue:` (задачи до перехода) — сравнивать не с чем, и это
|
||
* не находка: молчание здесь честнее выдуманного «менялось».
|
||
*/
|
||
export function issueBodyChanged(docs, digest) {
|
||
const numbered = (docs || [])
|
||
.map((doc) => ({ ...doc, round: Number((String(doc?.name || '').match(/-r(\d+)\.md$/) || [])[1]) }))
|
||
.filter((doc) => Number.isFinite(doc.round))
|
||
.sort((a, b) => b.round - a.round);
|
||
const green = numbered.find((doc) => anchorVerdictFrom(doc.text)?.verdict === 'green');
|
||
if (!green) return null;
|
||
const recorded = anchorIssueBodyFrom(green.text);
|
||
if (!recorded || !digest) return null;
|
||
return recorded === digest ? null : { doc: green.name, recorded, current: digest };
|
||
}
|
||
|
||
export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody } = {}) {
|
||
const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : '');
|
||
const lines = [
|
||
ANCHOR_MARKER,
|
||
'',
|
||
'## Материал раунда',
|
||
'',
|
||
`- Ветка: \`${branch || 'dev'}\`, коммит \`${short(sha)}\` — ребейз его осиротит,`
|
||
+ ' и это нормально: ниже якоря, которые ребейз не меняет.',
|
||
];
|
||
if (tree) {
|
||
lines.push(`- Дерево материала: \`${tree}\``);
|
||
lines.push(' ```');
|
||
lines.push(` git log --all --format='%H %T' | grep ${short(tree)}`);
|
||
lines.push(' ```');
|
||
}
|
||
// Тело issue — материал ревью ТЗ на лёгком и (с #517) на полном треке. Файл
|
||
// ТЗ может отсутствовать вовсе; хеш тела есть всегда, и он единственное,
|
||
// чем доказуемо «вердикт вынесен на этом тексте»: GitHub хранит правки тела
|
||
// без diff и без истории, доступной чтением.
|
||
if (issueBody) {
|
||
lines.push(`- Тело issue: \`${issueBody}\``);
|
||
}
|
||
for (const spec of specs) {
|
||
lines.push(`- ТЗ \`${spec.path}\`, блоб \`${spec.blob}\``);
|
||
lines.push(' ```');
|
||
lines.push(` git log --all --find-object=${spec.blob} -- ${spec.path}`);
|
||
lines.push(' ```');
|
||
}
|
||
if (!tree && !specs.length && !issueBody) {
|
||
lines.push('- Якоря снять не удалось: ветки задачи нет, материал читался по `dev`.');
|
||
}
|
||
// Вердикт из structured_output модели, записанный конвейером (#499): по нему
|
||
// следующий заход решает, можно ли применить зелёный вердикт повторно без
|
||
// вызова модели. Прозу документа для этого читать нельзя — она цитирует
|
||
// прошлые раунды и пишется в свободной форме.
|
||
if (verdict) {
|
||
lines.push(`- Вердикт конвейера: \`${verdict}\` · High ${Number.isFinite(Number(high)) ? Number(high) : '?'}`);
|
||
}
|
||
return `${lines.join('\n')}\n`;
|
||
}
|
||
|
||
/**
|
||
* Разбор строки `--specs`: `blob путь;blob путь;`.
|
||
*
|
||
* Формат сырой намеренно: он рождается в `git ls-files -s` внутри workflow, и
|
||
* любая промежуточная сериализация здесь была бы лишним местом для ошибки.
|
||
*/
|
||
export function parseSpecList(raw) {
|
||
return String(raw ?? '')
|
||
.split(';')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean)
|
||
.map((item) => {
|
||
const [blob, ...rest] = item.split(/\s+/);
|
||
return { blob, path: rest.join(' ') };
|
||
})
|
||
.filter((item) => /^[0-9a-f]{40}$/.test(item.blob) && item.path);
|
||
}
|
||
|
||
/** Дописать или заменить блок якорей в тексте документа. */
|
||
export function withMaterialAnchors(text, anchors) {
|
||
const body = String(text ?? '');
|
||
const at = body.indexOf(ANCHOR_MARKER);
|
||
const head = at >= 0 ? body.slice(0, at).replace(/\s+$/, '') : body.replace(/\s+$/, '');
|
||
return `${head}\n\n---\n\n${materialAnchorBlock(anchors)}`;
|
||
}
|
||
|
||
/**
|
||
* Счёт раундов ревью по ОПУБЛИКОВАННЫМ АРТЕФАКТАМ, а не по прозе вердикта (#454).
|
||
*
|
||
* Что было. `guard` определял номер захода и расход бюджета §4, считая
|
||
* комментарии issue, которые содержат и слово «Вердикт:», и буквальную
|
||
* подстроку маркера этапа (`SPEC-REVIEW`/`CODE-REVIEW`). Маркер попадал в тело
|
||
* только если ревьюер сам называл имя файла — то есть счёт зависел от
|
||
* формулировки. На #449 первый спек-вердикт имени файла не назвал, заход r2
|
||
* получил номер r1, и шаг публикации записал документ второго раунда поверх
|
||
* документа первого: `SPEC-REVIEW-449-r1.md` имеет две ревизии, и жёлтый
|
||
* вердикт первого раунда утрачен безвозвратно.
|
||
*
|
||
* Почему недосчёт оказался дороже, чем считалось. Комментарий в `process.yml`
|
||
* называл его обратимой ошибкой: «недосчёт даёт лишний заход». Это верно для
|
||
* бюджета §4 и неверно для имени документа — номер захода входит в путь
|
||
* `docs/reviews/<MARKER>-<NUM>-r<N>.md`, поэтому повтор номера не «лишний
|
||
* заход», а потеря артефакта.
|
||
*
|
||
* Источник истины поэтому меняется: раунд существовал, если существует его
|
||
* документ. Имя файла несёт и этап, и номер, и никем не сочиняется — его
|
||
* собирает сам конвейер.
|
||
*/
|
||
|
||
/** Маркер этапа в имени документа. */
|
||
export const REVIEW_STAGE_MARKERS = { spec: 'SPEC-REVIEW', code: 'CODE-REVIEW' };
|
||
|
||
/**
|
||
* Номера раундов, для которых на ветке лежит документ.
|
||
*
|
||
* Возвращает и `skipped` — имена, похожие на документ этапа, но с нечисловым
|
||
* суффиксом. Молча их игнорировать нельзя: такое имя означает либо опечатку
|
||
* ревьюера, либо изменение формата, и в обоих случаях счёт занижается.
|
||
*/
|
||
export function reviewRoundsFromFiles(names, marker, num) {
|
||
if (!/^[A-Z-]+$/.test(String(marker || '')) || !/^[0-9]+$/.test(String(num || ''))) {
|
||
return { rounds: [], skipped: [] };
|
||
}
|
||
const exact = new RegExp('^(?:.*/)?' + marker + '-' + num + '-r([0-9]+)\\.md$');
|
||
const loose = new RegExp('^(?:.*/)?' + marker + '-' + num + '-r(.+)\\.md$');
|
||
const rounds = [];
|
||
const skipped = [];
|
||
for (const raw of names || []) {
|
||
const name = String(raw).trim();
|
||
if (!name) continue;
|
||
const hit = exact.exec(name);
|
||
if (hit) { rounds.push(Number(hit[1])); continue; }
|
||
if (loose.test(name)) skipped.push(name);
|
||
}
|
||
return { rounds: [...new Set(rounds)].sort((a, b) => a - b), skipped };
|
||
}
|
||
|
||
/**
|
||
* Номер следующего захода: `max(r) + 1`, а НЕ `count + 1`.
|
||
*
|
||
* Разница видна ровно там, где дефект уже сработал. У #449 после коллизии
|
||
* файлов два, `r1` и `r2`, и оба счёта дали бы `r3`. Но при дыре в нумерации
|
||
* (`r1`, `r3` — а дыру оставляет как раз коллизия) счёт по количеству выдал бы
|
||
* `r3`, то есть ЗАНЯТОЕ имя, и следующий документ затёр бы существующий.
|
||
* Максимум занятого номера — единственная величина, которая гарантирует
|
||
* свободное имя.
|
||
*/
|
||
export function attemptFromRounds(rounds) {
|
||
const known = (rounds || []).filter((value) => Number.isFinite(value));
|
||
return known.length ? Math.max(...known) + 1 : 1;
|
||
}
|
||
|
||
/**
|
||
* Строка, ОБЪЯВЛЯЮЩАЯ вердикт (а не упоминающая слово).
|
||
*
|
||
* Двоеточие обязано стоять сразу за словом. Проверено на корпусе:
|
||
* `CODE-REVIEW-441-r1.md` (в нём, кстати, тоже лежит документ ВТОРОГО раунда —
|
||
* та же коллизия, что чинится) описывает предыдущий раунд строкой
|
||
* «- Вердикт r1: жёлтый, High 0…». Правило, допускавшее слово между «Вердикт»
|
||
* и двоеточием, засчитывало эту строку объявлением и добавляло зелёному
|
||
* раунду блокирующий цикл. Ошибка необратимая: лишний цикл останавливает
|
||
* работу по §4.
|
||
*/
|
||
const VERDICT_DECLARATION = /^[>\s]*(?:[-*+]\s*)?\*{0,2}Вердикт\*{0,2}\s*:/;
|
||
/** Заголовок раздела вердикта: «## Вердикт», «## 6. Вердикт». */
|
||
// `\b` здесь не работает: он ASCII-словесный, а «т» кириллическая, и граница
|
||
// после неё не находится. Хвост ограничивается явно.
|
||
const VERDICT_SECTION = /^#{1,6}[\s\d.)]*Вердикт(?![\wА-Яа-яЁё])/;
|
||
const BLOCKING_COLOUR = /(жёлт|желт|красн)/i;
|
||
const VERDICT_COLOUR = /(жёлт|желт|красн|зелён|зелен)/i;
|
||
|
||
/**
|
||
* Строка вердикта документа, либо `null`.
|
||
*
|
||
* Правило нарочно СТРОГОЕ и односторонне осторожное: пропустить вердикт не
|
||
* страшно — страховка максимумом доберёт его по комментариям, — а лишний
|
||
* блокирующий цикл ошибка необратимая: он останавливает работу по §4.
|
||
* Поэтому:
|
||
*
|
||
* - строка обязана НАЧИНАТЬ объявление, а не содержать слово. Замерено по
|
||
* корпусу 629 опубликованных документов: свободное упоминание встречается и
|
||
* выглядит опасно — `CODE-REVIEW-230-r2.md` цитирует жёлтый вердикт ПРОШЛОГО
|
||
* раунда («Комментарий с вердиктом r1 … «Вердикт: жёлтый …»»), а
|
||
* `SPEC-REVIEW-449-r2.md` разбирает подстроку `Вердикт:` как предмет задачи.
|
||
* Свободный поиск засчитал бы обоим лишний блокирующий цикл;
|
||
* - текст внутри тройных кавычек игнорируется — кроме блока, стоящего прямо
|
||
* под заголовком раздела «Вердикт»: там конвейер печатает точную копию
|
||
* комментария (`CODE-REVIEW-292-r1.md`), и это объявление, а не цитата.
|
||
*/
|
||
export function verdictDeclaration(text) {
|
||
const lines = String(text ?? '').split('\n');
|
||
let fenced = false;
|
||
let inSection = false;
|
||
let fencedHit = null;
|
||
for (const line of lines) {
|
||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||
if (!fenced && VERDICT_SECTION.test(line)) { inSection = true; continue; }
|
||
if (!fenced && /^#{1,6}\s/.test(line)) inSection = false;
|
||
if (!VERDICT_DECLARATION.test(line) || !VERDICT_COLOUR.test(line)) continue;
|
||
if (!fenced) return line;
|
||
if (inSection && !fencedHit) fencedHit = line;
|
||
}
|
||
return fencedHit;
|
||
}
|
||
|
||
/** Цикл израсходован, если вердикт вернул работу автору (#227). */
|
||
export function isBlockingVerdict(line) {
|
||
return Boolean(line) && BLOCKING_COLOUR.test(line);
|
||
}
|
||
|
||
/** Дерево материала из блока якорей документа (#414), либо `null`. */
|
||
export function anchorTreeFrom(text) {
|
||
const body = String(text ?? '');
|
||
const at = body.indexOf(ANCHOR_MARKER);
|
||
if (at < 0) return null;
|
||
const match = body.slice(at).match(/Дерево материала: `([0-9a-f]{40})`/);
|
||
return match ? match[1] : null;
|
||
}
|
||
|
||
/** Вердикт конвейера из блока якорей (#499): `{ verdict, high }` либо `null`. */
|
||
export function anchorVerdictFrom(text) {
|
||
const body = String(text ?? '');
|
||
const at = body.indexOf(ANCHOR_MARKER);
|
||
if (at < 0) return null;
|
||
const match = body.slice(at).match(/Вердикт конвейера: `(green|yellow|red)` · High (\d+)/);
|
||
return match ? { verdict: match[1], high: Number(match[2]) } : null;
|
||
}
|
||
|
||
/**
|
||
* Повторное применение зелёного вердикта без вызова модели (#499).
|
||
*
|
||
* Сценарий #437 r4: зелёный r3 не слился (страж #312 не признал вершину), задача
|
||
* вернулась в S6 и тут же в S7 — и ревьюер двенадцать минут заново разбирал
|
||
* дерево, в котором с r3 изменился ровно один файл: собственный документ r3.
|
||
*
|
||
* Правило узкое и одностороннее: вердикт переиспользуется ТОЛЬКО если
|
||
* - последний опубликованный документ этапа несёт записанный КОНВЕЙЕРОМ
|
||
* вердикт `green` с High 0 (из structured_output модели, не из прозы);
|
||
* - его якорь «дерево материала» снят конвейером (#414), а не написан рукой;
|
||
* - текущее дерево отличается от якоря НИЧЕМ, кроме docs/reviews/** (и
|
||
* переноса выпущенных документов в legacy/reviews/**, #682) —
|
||
* сравнение делает git по содержимому, так что ребейз на ушедший dev,
|
||
* правка теста, фикстуры, скрипта или ТЗ в docs/specs дают отличие и
|
||
* полный разбор (§2.10). Смена базы без изменения дерева невозможна:
|
||
* дерево ветки после ребейза включает содержимое dev.
|
||
* Любое сомнение — `null`, и ревью идёт как обычно.
|
||
*
|
||
* @param docs [{ name, text }] опубликованные документы этапа этой задачи
|
||
* @param differs (tree) => boolean — есть ли отличие дерева от HEAD вне docs/reviews
|
||
*/
|
||
export function reusableGreenVerdict(docs, differs, issueBodyDigest = null) {
|
||
const numbered = (docs || [])
|
||
.map((doc) => ({ ...doc, round: Number((String(doc?.name || '').match(/-r(\d+)\.md$/) || [])[1]) }))
|
||
.filter((doc) => Number.isFinite(doc.round))
|
||
.sort((a, b) => b.round - a.round);
|
||
const latest = numbered[0];
|
||
if (!latest) return null;
|
||
// Судится запись конвейера, не проза: документ r4 по #437 держит вердикт
|
||
// словом «Зелёный.» под заголовком раздела и цитирует r3 строкой «Вердикт
|
||
// r3: зелёный» — ни то, ни другое не годится как машинный источник.
|
||
const recorded = anchorVerdictFrom(latest.text);
|
||
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) return null;
|
||
const tree = anchorTreeFrom(latest.text);
|
||
if (!tree) return null;
|
||
if (differs(tree)) return null;
|
||
// Дерево не знает о теле issue, а с #517 ТЗ живёт именно там: без этой
|
||
// проверки правка ТЗ между раундами проходила бы невидимой — вызов модели
|
||
// пропущен, находка «ТЗ менялось» некому напечатать (ревью ТЗ #517 r1).
|
||
// Документ без записи (весь бэклог до перехода) судится по дереву, как
|
||
// раньше: иначе переход обнулил бы reuse для всех накопленных задач.
|
||
const recordedBody = anchorIssueBodyFrom(latest.text);
|
||
if (recordedBody && issueBodyDigest && recordedBody !== issueBodyDigest) return null;
|
||
return { doc: latest.name, round: latest.round, tree, verdict: 'green' };
|
||
}
|
||
|
||
/**
|
||
* Блокирующие раунды по документам: `{ blocking, unread }`.
|
||
*
|
||
* `unread` — документы, в которых объявления вердикта нет. Их около половины
|
||
* корпуса: формат отчёта исторически свободный. Это не дефект счёта, а его
|
||
* граница, и она обязана быть видимой в логе прогона — по такому документу
|
||
* цикл добирается только страховкой.
|
||
*/
|
||
export function blockingFromDocs(docs) {
|
||
const blocking = [];
|
||
const unread = [];
|
||
for (const doc of docs || []) {
|
||
const line = verdictDeclaration(doc && doc.text);
|
||
if (!line) { unread.push((doc && doc.name) || '(без имени)'); continue; }
|
||
if (isBlockingVerdict(line)) blocking.push((doc && doc.name) || '(без имени)');
|
||
}
|
||
return { blocking, unread };
|
||
}
|
||
|
||
/**
|
||
* Вердикты этапа среди комментариев issue — вторая, страховочная половина
|
||
* счёта (#454).
|
||
*
|
||
* Прежнее правило было двумя тестами подстроки по всему телу: `Вердикт:` и имя
|
||
* маркера. Оба ловят прозу. Поймано на самой этой задаче: guard кода #454
|
||
* насчитал заход r3 при первом же код-ревью, потому что маркер `CODE-REVIEW`
|
||
* случайно встретился в зелёном вердикте СПЕК-ревью и в комментарии-передаче
|
||
* работы — оба разбирали историю чужих задач и цитировали имена их документов.
|
||
* Завышение здесь опаснее занижения: попади заражающий вердикт в свой этап
|
||
* жёлтым, бюджет §4 сгорел бы без единого настоящего цикла — ровно вред
|
||
* класса #89, от которого этап и отделяли.
|
||
*
|
||
* Поэтому два условия вместо двух подстрок:
|
||
*
|
||
* - комментарий ОБЪЯВЛЯЕТ вердикт (та же строгая строка, что и в документе), а
|
||
* не упоминает слово «вердикт» в разборе;
|
||
* - он называет документ ЭТОЙ задачи и ЭТОГО этапа: `<MARKER>-<NUM>`. Голое
|
||
* имя маркера больше не годится — именно оно и протекало.
|
||
*/
|
||
export function stageVerdictComments(comments, marker, num) {
|
||
if (!/^[A-Z-]+$/.test(String(marker || '')) || !/^\d+$/.test(String(num || ''))) return [];
|
||
const own = new RegExp(`${marker}-${num}(?![0-9])`);
|
||
return (comments || [])
|
||
.map((item) => (typeof item === 'string' ? { body: item } : (item || {})))
|
||
.filter((item) => own.test(String(item.body ?? '')))
|
||
.map((item) => ({ ...item, verdict: verdictDeclaration(item.body) }))
|
||
.filter((item) => Boolean(item.verdict));
|
||
}
|
||
|
||
/** Счётчики по комментариям в том же виде, в каком их даёт файловая половина. */
|
||
export function commentCounters(comments, marker, num) {
|
||
const verdicts = stageVerdictComments(comments, marker, num);
|
||
const blocking = verdicts.filter((item) => isBlockingVerdict(item.verdict));
|
||
return {
|
||
attempt: verdicts.length + 1,
|
||
spent: blocking.length,
|
||
list: blocking.map((item) => item.url).filter(Boolean),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Итоговые счётчики: максимум двух независимых источников.
|
||
*
|
||
* Максимум, а не сумма и не выбор одного. Шаг публикации может упасть уже
|
||
* после того, как вердикт опубликован комментарием, — тогда документа нет, а
|
||
* цикл израсходован. И наоборот, комментарий может не назвать файл — тогда
|
||
* есть документ, а прозы нет. Недосчёт возможен только при отказе ОБОИХ
|
||
* источников; перерасчёт невозможен по построению.
|
||
*/
|
||
export function reviewCounters({ rounds = [], docs = [], comments = {} } = {}) {
|
||
const attemptFiles = attemptFromRounds(rounds);
|
||
const { blocking, unread } = blockingFromDocs(docs);
|
||
const spentFiles = blocking.length;
|
||
const attemptComments = Number.isFinite(Number(comments.attempt)) ? Number(comments.attempt) : 1;
|
||
const spentComments = Number.isFinite(Number(comments.spent)) ? Number(comments.spent) : 0;
|
||
return {
|
||
attempt: Math.max(attemptFiles, attemptComments),
|
||
spent: Math.max(spentFiles, spentComments),
|
||
attemptFiles,
|
||
spentFiles,
|
||
attemptComments,
|
||
spentComments,
|
||
blocking,
|
||
unread,
|
||
};
|
||
}
|
||
|
||
// #496: pathToFileURL, не `file://${argv}` — на Windows последнее давало
|
||
// `file:///C:/C:/...`, CLI считал себя импортированным и молчал.
|
||
const invokedDirectly = isMainModule(import.meta.url);
|
||
if (invokedDirectly) {
|
||
const argv = process.argv.slice(2);
|
||
// Режим счёта раундов (#454): на входе список имён и тела документов,
|
||
// на выходе `attempt`/`spent` в формате GITHUB_OUTPUT.
|
||
//
|
||
// Сбор данных остаётся в workflow (там есть `gh` и токен), решение — здесь:
|
||
// счёт обязан быть покрыт тестами, а inline-shell тестами не покрывается.
|
||
if (argv.includes('--counters')) {
|
||
const value = (name, fallback = '') => {
|
||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||
return found ? found.slice(name.length + 3) : fallback;
|
||
};
|
||
const marker = value('marker');
|
||
const num = value('num');
|
||
const namesFile = value('names');
|
||
const docsDir = value('docs');
|
||
let names = [];
|
||
try {
|
||
names = readFileSync(namesFile, 'utf8').split('\n');
|
||
} catch (error) {
|
||
console.error(`::warning::список документов не прочитан (${error.code || error.message})`
|
||
+ ' — счёт по файлам отключён, работает страховка по комментариям');
|
||
}
|
||
const { rounds, skipped } = reviewRoundsFromFiles(names, marker, num);
|
||
for (const name of skipped) {
|
||
console.error(`::warning::документ ${name} не даёт номера раунда:`
|
||
+ ' суффикс -r<N> нечисловой, раунд в счёт не попал');
|
||
}
|
||
const docs = [];
|
||
for (const round of rounds) {
|
||
const name = `${marker}-${num}-r${round}.md`;
|
||
try {
|
||
docs.push({ name, text: readFileSync(`${docsDir}/${name}`, 'utf8') });
|
||
} catch (error) {
|
||
console.error(`::warning::документ ${name} не прочитан`
|
||
+ ` (${error.code || error.message}) — цикл по нему не засчитан`);
|
||
}
|
||
}
|
||
// Комментарии — вторая половина счёта. Разбор их тоже здесь, а не в jq:
|
||
// прежнее правило «подстрока в теле» протекало на прозе (см.
|
||
// stageVerdictComments), и чинить его в inline-shell означало бы снова
|
||
// оставить счёт без единого теста.
|
||
let fromComments = { attempt: Number(value('comment-attempt', '1')),
|
||
spent: Number(value('comment-spent', '0')), list: [] };
|
||
const commentsFile = value('comments');
|
||
if (commentsFile) {
|
||
try {
|
||
const payload = JSON.parse(readFileSync(commentsFile, 'utf8'));
|
||
fromComments = commentCounters(payload.comments || payload, marker, num);
|
||
} catch (error) {
|
||
console.error(`::warning::комментарии не разобраны (${error.code || error.message})`
|
||
+ ' — счёт по комментариям отключён, работает счёт по документам');
|
||
fromComments = { attempt: 1, spent: 0, list: [] };
|
||
}
|
||
}
|
||
const counters = reviewCounters({ rounds, docs, comments: fromComments });
|
||
if (counters.unread.length) {
|
||
console.error(`::warning::вердикт не объявлен машиночитаемой строкой в:`
|
||
+ ` ${counters.unread.join(', ')} — цикл по этим документам считается`
|
||
+ ' только по комментариям');
|
||
}
|
||
// Расхождение источников — не отказ, но и не рутина: оно означает, что один
|
||
// из них чего-то не видит. Пусть это будет видно в прогоне, а не только в
|
||
// арифметике максимума.
|
||
if (counters.attemptComments !== counters.attemptFiles) {
|
||
console.error('::warning::источники счёта расходятся: по документам заход'
|
||
+ ` ${counters.attemptFiles}, по комментариям ${counters.attemptComments}.`
|
||
+ ' Взят максимум. Документы надёжнее: их имена собирает конвейер.');
|
||
}
|
||
console.error(`::notice::раунды по файлам ${rounds.length ? rounds.join(',') : '—'};`
|
||
+ ` заход: файлы ${counters.attemptFiles}, комментарии ${counters.attemptComments};`
|
||
+ ` циклы: файлы ${counters.spentFiles}, комментарии ${counters.spentComments}`);
|
||
console.log(`attempt=${counters.attempt}`);
|
||
console.log(`spent=${counters.spent}`);
|
||
// Перечень учтённого по файлам: без него владелец видит число, но не может
|
||
// сверить, из чего оно сложилось, когда комментарии цикла не показывают.
|
||
console.log(`blocking=${counters.blocking.join(', ')}`);
|
||
const listFile = value('spent-list');
|
||
if (listFile) {
|
||
try {
|
||
writeFileSync(listFile, fromComments.list.map((url) => `- ${url}`).join('\n'), 'utf8');
|
||
} catch (error) {
|
||
console.error(`::warning::перечень учтённых вердиктов не записан (${error.code || error.message})`);
|
||
}
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
// Режим повторного применения зелёного вердикта (#499): документы этапа
|
||
// читаются из рабочей копии (они в дереве ветки), отличие дерева судит git.
|
||
// --reuse --marker=CODE-REVIEW --num=437 [--head=HEAD]
|
||
// Печатает `reuse=true|false` и, при true, `doc=`, `round=`, `tree=`, `verdict=`.
|
||
if (argv.includes('--reuse')) {
|
||
const value = (name) => {
|
||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||
return found ? found.slice(name.length + 3) : '';
|
||
};
|
||
const marker = value('marker'); const num = value('num'); const head = value('head') || 'HEAD';
|
||
const listed = spawnSync('git', ['ls-tree', '--name-only', `${head}:docs/reviews`], { encoding: 'utf8' });
|
||
const names = (listed.stdout || '').split('\n')
|
||
.filter((name) => new RegExp(`^${marker}-${num}-r\\d+\\.md$`).test(name));
|
||
const docs = names.map((name) => {
|
||
const shown = spawnSync('git', ['show', `${head}:docs/reviews/${name}`], { encoding: 'utf8' });
|
||
return { name, text: shown.status === 0 ? shown.stdout : '' };
|
||
});
|
||
const differs = (tree) => {
|
||
// Сначала дерево обязано существовать: неизвестный объект — не «совпало».
|
||
const exists = spawnSync('git', ['cat-file', '-e', `${tree}^{tree}`], { encoding: 'utf8' });
|
||
if (exists.status !== 0) return true;
|
||
// #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'));
|
||
if (!found) { process.stdout.write('reuse=false\n'); process.exit(0); }
|
||
process.stdout.write(`reuse=true\ndoc=${found.doc}\nround=${found.round}\ntree=${found.tree}\n`);
|
||
process.exit(0);
|
||
}
|
||
|
||
// Режим дописывания якорей (#414): конвейер снял их при чтении материала.
|
||
const anchorArg = argv.find((item) => item.startsWith('--anchor='));
|
||
if (anchorArg) {
|
||
const path = anchorArg.slice('--anchor='.length);
|
||
const value = (name) => {
|
||
const found = argv.find((item) => item.startsWith(`--${name}=`));
|
||
return found ? found.slice(name.length + 3) : '';
|
||
};
|
||
const anchors = {
|
||
sha: value('sha'),
|
||
tree: value('tree'),
|
||
branch: value('branch'),
|
||
issueBody: /^[0-9a-f]{64}$/.test(value('issue-body')) ? value('issue-body') : '',
|
||
specs: parseSpecList(value('specs')),
|
||
verdict: ['green', 'yellow', 'red'].includes(value('verdict')) ? value('verdict') : '',
|
||
high: value('high'),
|
||
};
|
||
const text = readFileSync(path, 'utf8');
|
||
writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8');
|
||
console.log(`якоря материала дописаны: дерево ${anchors.tree.slice(0, 12) || '—'},`
|
||
+ ` ТЗ ${anchors.specs.length}, тело issue ${anchors.issueBody.slice(0, 12) || '—'}`);
|
||
process.exit(0);
|
||
}
|
||
|
||
// Режим проверки объявленного материала (#413): на входе сам документ.
|
||
const docArg = argv.find((item) => item.startsWith('--doc='));
|
||
if (docArg) {
|
||
const path = docArg.slice('--doc='.length);
|
||
let text;
|
||
try {
|
||
text = readFileSync(path === '-' ? 0 : path, 'utf8');
|
||
} catch (error) {
|
||
console.error(`::error::ревью-документ не прочитан: ${path} (${error.code || error.message})`);
|
||
process.exit(1);
|
||
}
|
||
const resolveReachable = (shas) => {
|
||
const map = new Map(shas.map((sha) => [sha, null]));
|
||
for (const sha of shas) {
|
||
const probe = spawnSync('git', [
|
||
'for-each-ref', '--contains', sha, '--count=1',
|
||
'--format=%(refname)', 'refs/remotes/origin', 'refs/tags',
|
||
], { encoding: 'utf8' });
|
||
const ref = (probe.stdout || '').trim().split('\n')[0];
|
||
if (probe.status === 0 && ref) map.set(sha, ref);
|
||
}
|
||
return map;
|
||
};
|
||
const resolveObjects = (object) => anchorLiveness(object, (args) => spawnSync('git', args, {
|
||
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||
}));
|
||
const verdict = danglingMaterialRefusal(
|
||
text, resolveReachable, REVIEW_HEADER_LINES, resolveObjects,
|
||
);
|
||
if (verdict && verdict.warning) {
|
||
console.log(`::warning::${verdict.warning}`);
|
||
} else if (verdict) {
|
||
console.error(`::error::${verdict.split('\n')[0]}`);
|
||
console.error(verdict);
|
||
process.exit(1);
|
||
}
|
||
const cited = citedMaterialShas(text);
|
||
if (!(verdict && verdict.warning)) {
|
||
console.log(cited.length
|
||
? `материал раунда объявлен и достижим с origin: ${cited.map((item) => item.sha).join(', ')}`
|
||
: 'материал раунда в шапке не объявлен — проверять нечего');
|
||
}
|
||
process.exit(0);
|
||
}
|
||
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(', ')}`);
|
||
}
|